@myazahq/kyc-sdk-react-native 2.4.0 → 2.6.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 (246) hide show
  1. package/README.md +151 -5
  2. package/android/build.gradle +35 -3
  3. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +77 -0
  4. package/app.plugin.js +89 -8
  5. package/ios/HybridMyazaFaceDetector.swift +16 -3
  6. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.cpp +23 -0
  7. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.hpp +2 -0
  8. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaFaceDetectorSpec.kt +9 -0
  9. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.cpp +8 -0
  10. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.hpp +43 -0
  11. package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.hpp +17 -0
  12. package/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
  13. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec.swift +2 -0
  14. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec_cxx.swift +31 -0
  15. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.cpp +2 -0
  16. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.hpp +3 -0
  17. package/package.json +14 -2
  18. package/src/MyazaBiometricAuth.tsx +119 -0
  19. package/src/MyazaKYC.tsx +12 -0
  20. package/src/components/CountryField.tsx +8 -0
  21. package/src/components/CountryRegionPicker.tsx +184 -0
  22. package/src/components/DashedBorder.tsx +55 -0
  23. package/src/components/DialCodePicker.tsx +53 -67
  24. package/src/components/DialCodeRow.tsx +102 -0
  25. package/src/components/FramedMapPicker.tsx +122 -0
  26. package/src/components/GeoBadge.tsx +34 -0
  27. package/src/components/Icon.tsx +2 -176
  28. package/src/components/KycFlow.tsx +60 -32
  29. package/src/components/LineSkeleton.tsx +100 -0
  30. package/src/components/MapChrome.tsx +67 -0
  31. package/src/components/MapPinMarker.tsx +54 -0
  32. package/src/components/MapPinPicker.tsx +179 -0
  33. package/src/components/MilestoneTrack.tsx +155 -0
  34. package/src/components/MultiIdProgress.tsx +107 -0
  35. package/src/components/MyazaInput.tsx +39 -4
  36. package/src/components/MyazaSelect.tsx +2 -1
  37. package/src/components/OptionRow.tsx +22 -3
  38. package/src/components/OwnershipSlider.tsx +102 -0
  39. package/src/components/PhoneNumberInput.tsx +45 -4
  40. package/src/components/PoweredBy.tsx +5 -5
  41. package/src/components/PresenceBlocks.tsx +153 -0
  42. package/src/components/StaggerIn.tsx +63 -0
  43. package/src/components/StepView.tsx +43 -0
  44. package/src/components/StickyActions.tsx +56 -0
  45. package/src/components/dialCodeRows.ts +64 -0
  46. package/src/components/glass/FloatingSheet.tsx +73 -21
  47. package/src/components/glass/GlassSheet.tsx +14 -2
  48. package/src/components/icon-map.ts +176 -0
  49. package/src/components/stepHeaderMeta.tsx +45 -5
  50. package/src/config/addressCollection.ts +125 -0
  51. package/src/config/biometricOptions.ts +100 -0
  52. package/src/config/business.ts +26 -7
  53. package/src/config/businessDetailsValidity.ts +52 -0
  54. package/src/config/businessPrefill.ts +72 -0
  55. package/src/config/businessSteps.ts +20 -3
  56. package/src/config/consentStep.ts +19 -0
  57. package/src/config/keyPeople.ts +111 -7
  58. package/src/config/keyPeopleCorporate.ts +19 -0
  59. package/src/config/keyPeopleOwnerTypes.ts +12 -0
  60. package/src/config/keyPeopleOwners.ts +26 -0
  61. package/src/config/keyPeoplePrefill.ts +138 -0
  62. package/src/config/keyPeopleSectionDefs.ts +65 -0
  63. package/src/config/keyPeopleSections.ts +167 -0
  64. package/src/config/proofOfAddress.ts +54 -11
  65. package/src/config/regions.ts +24 -0
  66. package/src/config/registrationHint.ts +1 -1
  67. package/src/config/stepOrder.ts +100 -6
  68. package/src/config/theme.ts +8 -2
  69. package/src/config/uploadLimits.ts +35 -0
  70. package/src/config/website.ts +36 -0
  71. package/src/config/workflowMerge.ts +16 -5
  72. package/src/emrtd/activeAuth.ts +100 -0
  73. package/src/emrtd/extras.ts +76 -0
  74. package/src/emrtd/files.ts +5 -0
  75. package/src/emrtd/index.ts +1 -0
  76. package/src/emrtd/read.ts +8 -1
  77. package/src/emrtd/session.ts +41 -0
  78. package/src/emrtd/stages.ts +11 -0
  79. package/src/index.ts +54 -1
  80. package/src/lib/address-current-location.ts +171 -0
  81. package/src/lib/address-field-modes.ts +119 -0
  82. package/src/lib/address-flow.ts +172 -0
  83. package/src/lib/address-helpers.ts +48 -0
  84. package/src/lib/address-line.ts +76 -0
  85. package/src/lib/address-pin-move.ts +97 -0
  86. package/src/lib/address-step-recovery.ts +63 -0
  87. package/src/lib/authed-image.ts +78 -0
  88. package/src/lib/biometric-auth.ts +52 -0
  89. package/src/lib/biometric-copy.ts +51 -0
  90. package/src/lib/captureRing.ts +83 -0
  91. package/src/lib/contact-recovery.ts +44 -0
  92. package/src/lib/country-adoption.ts +89 -0
  93. package/src/lib/inferred-country.ts +65 -0
  94. package/src/lib/livenessLayout.ts +49 -0
  95. package/src/lib/map-frame.ts +159 -0
  96. package/src/lib/map-tiles.ts +143 -0
  97. package/src/lib/multi-id.ts +210 -0
  98. package/src/lib/poa-country-gate.ts +27 -0
  99. package/src/lib/resubmit.ts +130 -0
  100. package/src/lib/result-copy.ts +116 -0
  101. package/src/lib/result-wait.ts +53 -0
  102. package/src/lib/review-map-surface.ts +26 -0
  103. package/src/lib/scope.ts +31 -0
  104. package/src/lib/screen-corners.ts +50 -0
  105. package/src/lib/selfie-upload-wait.ts +79 -0
  106. package/src/lib/step-log.ts +24 -3
  107. package/src/lib/street-view-fov.ts +42 -0
  108. package/src/lib/use-multi-id-plan.ts +29 -0
  109. package/src/lib/webview-available.ts +47 -0
  110. package/src/liveness/useLiveness.ts +6 -1
  111. package/src/liveness/useModelReady.ts +65 -0
  112. package/src/liveness/visionCameraFaceDetector.ts +35 -0
  113. package/src/presence/background-math.ts +100 -0
  114. package/src/presence/background-store.ts +82 -0
  115. package/src/presence/background.ts +165 -0
  116. package/src/presence/foreground-service.ts +193 -0
  117. package/src/presence/fs.ts +51 -0
  118. package/src/presence/geofence.ts +41 -0
  119. package/src/presence/math.ts +44 -0
  120. package/src/presence/post.ts +40 -0
  121. package/src/presence/report.ts +87 -0
  122. package/src/presence/sampler.ts +110 -0
  123. package/src/presence/status.ts +92 -0
  124. package/src/presence/store.ts +94 -0
  125. package/src/presence/tier.ts +36 -0
  126. package/src/presence/watch-wait.ts +81 -0
  127. package/src/screens/AddressCountryControl.tsx +115 -0
  128. package/src/screens/ApplicantRoleStep.tsx +9 -5
  129. package/src/screens/BusinessCheckPanel.tsx +89 -0
  130. package/src/screens/BusinessDetailsFields.tsx +112 -0
  131. package/src/screens/BusinessDetailsStep.tsx +133 -145
  132. package/src/screens/BusinessDocumentSlot.tsx +5 -5
  133. package/src/screens/BusinessDocumentsStep.tsx +5 -56
  134. package/src/screens/BusinessKeyPeopleStep.tsx +103 -71
  135. package/src/screens/BusinessPickedCard.tsx +86 -0
  136. package/src/screens/BusinessPickedSection.tsx +51 -0
  137. package/src/screens/BusinessRegistryPickers.tsx +67 -0
  138. package/src/screens/BusinessSandboxToggle.tsx +114 -0
  139. package/src/screens/BusinessSearch.tsx +199 -0
  140. package/src/screens/BusinessSearchResults.tsx +115 -0
  141. package/src/screens/CompanyInfoFields.tsx +91 -51
  142. package/src/screens/ConsentStep.tsx +37 -17
  143. package/src/screens/ContactActions.tsx +54 -0
  144. package/src/screens/ContactDestinationField.tsx +5 -0
  145. package/src/screens/ContactEntryPanel.tsx +75 -0
  146. package/src/screens/ContactVerificationStep.tsx +43 -42
  147. package/src/screens/CountrySelectStep.tsx +13 -147
  148. package/src/screens/IdTypeStep.tsx +12 -5
  149. package/src/screens/KeepLinksSheet.tsx +88 -0
  150. package/src/screens/KeyPeopleAwaitCard.tsx +204 -0
  151. package/src/screens/KeyPeopleAwaitList.tsx +126 -0
  152. package/src/screens/KeyPeoplePending.tsx +154 -0
  153. package/src/screens/KeyPeopleSection.tsx +162 -0
  154. package/src/screens/KeyPeopleSectionsList.tsx +103 -0
  155. package/src/screens/KeyPeopleUboExemption.tsx +72 -0
  156. package/src/screens/KeyPersonCard.tsx +36 -4
  157. package/src/screens/KeyPersonForm.tsx +195 -36
  158. package/src/screens/KeyPersonKindToggle.tsx +68 -0
  159. package/src/screens/KeyPersonOwners.tsx +109 -0
  160. package/src/screens/KeyPersonRoleChips.tsx +68 -0
  161. package/src/screens/KeyPersonSheet.tsx +92 -108
  162. package/src/screens/LivenessAvatar.tsx +15 -3
  163. package/src/screens/LivenessStep.tsx +172 -52
  164. package/src/screens/NfcStep.tsx +14 -0
  165. package/src/screens/PoaDocumentTypeList.tsx +66 -0
  166. package/src/screens/ProofOfAddressParts.tsx +151 -0
  167. package/src/screens/ProofOfAddressStep.tsx +58 -96
  168. package/src/screens/SubmittedBadge.tsx +25 -0
  169. package/src/screens/SubmittedError.tsx +64 -0
  170. package/src/screens/SubmittedResult.tsx +117 -0
  171. package/src/screens/SubmittedStep.tsx +82 -119
  172. package/src/screens/SubmittedSuccess.tsx +127 -0
  173. package/src/screens/SubmittedWaiting.tsx +45 -0
  174. package/src/screens/address/AddressEntranceStep.tsx +161 -0
  175. package/src/screens/address/AddressIntroGate.tsx +148 -0
  176. package/src/screens/address/AddressMapStub.tsx +50 -0
  177. package/src/screens/address/AddressPinStep.tsx +178 -0
  178. package/src/screens/address/AddressReviewStep.tsx +162 -0
  179. package/src/screens/address/AddressSandboxTabs.tsx +158 -0
  180. package/src/screens/address/AddressSearchField.tsx +115 -0
  181. package/src/screens/address/AddressSearchStep.tsx +94 -0
  182. package/src/screens/address/CurrentLocationRow.tsx +135 -0
  183. package/src/screens/address/DetailsSheet.tsx +175 -0
  184. package/src/screens/address/DetailsSheetFields.tsx +184 -0
  185. package/src/screens/address/EntranceDropzone.tsx +178 -0
  186. package/src/screens/address/EntranceFraming.tsx +66 -0
  187. package/src/screens/address/EntrancePills.tsx +60 -0
  188. package/src/screens/address/FramedStreetView.tsx +155 -0
  189. package/src/screens/address/IntroDisclosures.tsx +210 -0
  190. package/src/screens/address/LabelDecisionRow.tsx +113 -0
  191. package/src/screens/address/PinSummaryRow.tsx +105 -0
  192. package/src/screens/address/ReviewAddressBand.tsx +111 -0
  193. package/src/screens/address/ReviewEntranceThumbs.tsx +64 -0
  194. package/src/screens/address/ReviewMapPicture.tsx +106 -0
  195. package/src/screens/address/SearchResults.tsx +105 -0
  196. package/src/screens/address/SearchScreen.tsx +176 -0
  197. package/src/screens/address/SkipForNow.tsx +43 -0
  198. package/src/screens/address/StreetViewChrome.tsx +81 -0
  199. package/src/screens/address/detail-values.ts +22 -0
  200. package/src/screens/address/fix-source.ts +27 -0
  201. package/src/screens/address/index.ts +8 -0
  202. package/src/screens/address/meta.ts +44 -0
  203. package/src/screens/address/use-address-flow.ts +200 -0
  204. package/src/screens/address/use-label-pin.ts +80 -0
  205. package/src/screens/address/use-pin-actions.ts +192 -0
  206. package/src/screens/biometric/BiometricAuthFlow.tsx +170 -0
  207. package/src/screens/consent/model.ts +79 -10
  208. package/src/screens/liveness/CaptureRing.tsx +91 -0
  209. package/src/screens/liveness/LivenessCamera.tsx +63 -0
  210. package/src/screens/liveness/LivenessHandover.tsx +43 -0
  211. package/src/screens/liveness/LivenessOutcome.tsx +4 -3
  212. package/src/screens/liveness/SelfiePreview.tsx +13 -4
  213. package/src/screens/liveness/ShutterFlash.tsx +25 -0
  214. package/src/screens/liveness/index.ts +4 -0
  215. package/src/screens/liveness/useSelfieUpload.ts +23 -3
  216. package/src/screens/nfc/NfcReadProgress.tsx +1 -0
  217. package/src/screens/nfc/NfcScanIllustration.tsx +7 -18
  218. package/src/screens/useAddressPhotoAttach.ts +77 -0
  219. package/src/screens/useAwaitingPeople.ts +90 -0
  220. package/src/screens/useBusinessDocumentAttach.ts +93 -0
  221. package/src/screens/usePoaAttach.ts +12 -15
  222. package/src/services/api-biometric.ts +37 -0
  223. package/src/services/api-types-biometric.ts +41 -0
  224. package/src/services/api-types.ts +251 -3
  225. package/src/services/api-verify-types.ts +90 -1
  226. package/src/services/api.ts +237 -3
  227. package/src/services/deviceMetadata.ts +1 -1
  228. package/src/services/errors.ts +10 -2
  229. package/src/services/location.ts +234 -0
  230. package/src/services/uploadErrors.ts +30 -0
  231. package/src/services/workflowGate.ts +8 -0
  232. package/src/specs/MyazaFaceDetector.nitro.ts +29 -0
  233. package/src/store/address.ts +97 -0
  234. package/src/store/businessCheck.ts +113 -0
  235. package/src/store/derive.ts +44 -4
  236. package/src/store/kycStore.ts +352 -16
  237. package/src/store/serverConfig.ts +20 -0
  238. package/src/store/session.ts +227 -0
  239. package/src/store/state.ts +289 -2
  240. package/src/store/submit.ts +80 -13
  241. package/src/store/submitApplicant.ts +5 -11
  242. package/src/types/business.ts +23 -2
  243. package/src/types/config.ts +70 -2
  244. package/src/types/verification.ts +13 -1
  245. package/src/types/workflow.ts +112 -1
  246. package/src/screens/KeyPeopleInviteLinks.tsx +0 -239
@@ -0,0 +1,199 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import { Pressable, View } from 'react-native';
3
+
4
+ import { MyazaAlert } from '../components/MyazaAlert';
5
+ import { MyazaButton } from '../components/MyazaButton';
6
+ import { MyazaInput } from '../components/MyazaInput';
7
+ import { MyazaSelect } from '../components/MyazaSelect';
8
+ import { MyazaText } from '../components/Typography';
9
+ import { Icon } from '../components/Icon';
10
+ import { BusinessSearchResults } from './BusinessSearchResults';
11
+ import { useKyc, useKycStore, useTheme } from '../components/runtime';
12
+ import { spacing } from '../config/theme';
13
+ import type { BusinessSearchHit } from '../services/api';
14
+
15
+ // ─── Finding the company by name, because that is what people know ───────────
16
+ //
17
+ // A registration number is on a certificate in a drawer; the name is in the
18
+ // applicant's head. Asking for the number first turns the very first field
19
+ // into a search of their own filing cabinet, which is where KYB applications
20
+ // are abandoned.
21
+ //
22
+ // The search itself is FREE and read-only; only picking a result leads to the
23
+ // paid register check. It fires on an explicit submit, never on keystroke: a
24
+ // debounce spends the register's small, uncontracted rate-limit budget on
25
+ // every prefix nobody asked for. Sizes and rhythm mirror the web SDK's
26
+ // BusinessSearch (12px block gaps, 48px input/button) — keep them in lockstep.
27
+
28
+ type SearchState =
29
+ | { phase: 'idle' }
30
+ | { phase: 'searching' }
31
+ | { phase: 'results'; hits: BusinessSearchHit[]; truncated: boolean }
32
+ | { phase: 'unavailable' };
33
+
34
+ export function BusinessSearch({
35
+ country,
36
+ onPicked,
37
+ onManualEntry,
38
+ }: {
39
+ country: string;
40
+ onPicked: (hit: BusinessSearchHit) => void;
41
+ onManualEntry: () => void;
42
+ }): React.ReactElement {
43
+ const store = useKycStore();
44
+ const { colors } = useTheme();
45
+ const region = useKyc((s) => s.business.subdivisionCode);
46
+ const [query, setQuery] = useState('');
47
+ // Narrows what came back; it never replaces the search. Matches the name AND
48
+ // the number — a box inviting either that answered only one looked broken.
49
+ const [filter, setFilter] = useState('');
50
+ const [state, setState] = useState<SearchState>({ phase: 'idle' });
51
+ // Four countries file companies per state or emirate, and the provider
52
+ // searches ONE register at a time. Without the region it fails closed —
53
+ // a company filed a state away would otherwise come back "not registered".
54
+ const [regions, setRegions] = useState<{ code: string; name: string }[]>([]);
55
+
56
+ useEffect(() => {
57
+ let cancelled = false;
58
+ setRegions([]);
59
+ if (!country) return undefined;
60
+ store
61
+ .getState()
62
+ .api.businessRegions(country)
63
+ .then((r) => {
64
+ if (!cancelled) setRegions(r.regions ?? []);
65
+ })
66
+ // An empty list means "do not ask" — also the safe answer when the
67
+ // catalogue is unreachable: the search itself says so on its own.
68
+ .catch(() => undefined);
69
+ return () => {
70
+ cancelled = true;
71
+ };
72
+ }, [country, store]);
73
+
74
+ const run = async (): Promise<void> => {
75
+ const q = query.trim();
76
+ if (q.length < 2) return;
77
+ if (regions.length > 0 && !region) return;
78
+ setState({ phase: 'searching' });
79
+ try {
80
+ const res = await store.getState().api.businessSearch({
81
+ country,
82
+ query: q,
83
+ limit: 50,
84
+ ...(region ? { subdivisionCode: region } : {}),
85
+ });
86
+ const hits = res.results ?? [];
87
+ setFilter('');
88
+ setState({ phase: 'results', hits, truncated: hits.length >= 50 });
89
+ } catch {
90
+ // Deliberately NOT an empty result list. "No matches" reads as "this
91
+ // business is not registered", a claim an outage has not earned.
92
+ setState({ phase: 'unavailable' });
93
+ }
94
+ };
95
+
96
+ const searching = state.phase === 'searching';
97
+ const shown =
98
+ state.phase === 'results'
99
+ ? state.hits.filter((h) => {
100
+ const q = filter.trim().toLowerCase();
101
+ if (!q) return true;
102
+ return (
103
+ h.name.toLowerCase().includes(q) || h.registrationNumber.toLowerCase().includes(q)
104
+ );
105
+ })
106
+ : [];
107
+
108
+ return (
109
+ <View>
110
+ {regions.length > 0 && (
111
+ <View style={{ marginBottom: 12 }}>
112
+ <MyazaText variant="label" style={{ marginBottom: spacing.sm }}>
113
+ State or region of registration
114
+ </MyazaText>
115
+ <MyazaSelect
116
+ value={region || undefined}
117
+ sheetTitle="State or region"
118
+ searchable
119
+ options={regions.map((r) => ({ value: r.code, label: r.name }))}
120
+ onChange={(code) => store.getState().setBusinessField('subdivisionCode', code)}
121
+ />
122
+ </View>
123
+ )}
124
+
125
+ {/* No label: the placeholder says it, and the magnifier says what kind
126
+ of field this is — the web SDK's exact arrangement. */}
127
+ <MyazaInput
128
+ value={query}
129
+ onChangeText={setQuery}
130
+ placeholder="Search by company name"
131
+ autoCorrect={false}
132
+ prefix={<Icon name="search" size={16} color={colors.textMuted} />}
133
+ />
134
+ <View style={{ height: 12 }} />
135
+ {/* The spinner lives IN the button, as on web — a separate spinner row
136
+ pushed the results down and said the same thing twice. */}
137
+ <MyazaButton
138
+ label={searching ? 'Searching…' : 'Search'}
139
+ leadingIcon={searching ? undefined : 'search'}
140
+ loading={searching}
141
+ onPress={() => void run()}
142
+ disabled={searching || query.trim().length < 2 || (regions.length > 0 && !region)}
143
+ />
144
+
145
+ {state.phase === 'results' && state.hits.length > 0 && (
146
+ <BusinessSearchResults
147
+ country={country}
148
+ hits={state.hits}
149
+ shown={shown}
150
+ truncated={state.truncated}
151
+ filter={filter}
152
+ onFilter={setFilter}
153
+ onPicked={onPicked}
154
+ />
155
+ )}
156
+
157
+ {state.phase === 'results' && state.hits.length === 0 && (
158
+ <MyazaText variant="bodyMedium" color={colors.textSecondary} style={{ marginTop: 12 }}>
159
+ Nothing found under that name. Try a shorter version of it, or enter the details
160
+ yourself.
161
+ </MyazaText>
162
+ )}
163
+
164
+ {state.phase === 'unavailable' && (
165
+ <View style={{ marginTop: 12 }}>
166
+ <MyazaAlert
167
+ variant="warning"
168
+ title="Search is unavailable"
169
+ message="We could not reach the company register just now. Try again, or enter the details yourself and we will check them when you submit."
170
+ />
171
+ </View>
172
+ )}
173
+
174
+ {/* Always offered, not only on failure: some companies are not in the
175
+ index at all, and a dead end at the first step ends the application. */}
176
+ <Pressable
177
+ accessibilityRole="button"
178
+ onPress={onManualEntry}
179
+ hitSlop={8}
180
+ style={{
181
+ flexDirection: 'row',
182
+ alignItems: 'center',
183
+ gap: 6,
184
+ alignSelf: 'flex-start',
185
+ marginTop: 12,
186
+ }}
187
+ >
188
+ <Icon name="pencil" size={14} color={colors.textSecondary} />
189
+ <MyazaText
190
+ variant="bodyMedium"
191
+ color={colors.textSecondary}
192
+ style={{ fontWeight: '500' }}
193
+ >
194
+ Enter the details myself
195
+ </MyazaText>
196
+ </Pressable>
197
+ </View>
198
+ );
199
+ }
@@ -0,0 +1,115 @@
1
+ import React from 'react';
2
+ import { Pressable, ScrollView, View } from 'react-native';
3
+
4
+ import { CountryFlag } from '../components/CountryFlag';
5
+ import { Icon } from '../components/Icon';
6
+ import { MyazaInput } from '../components/MyazaInput';
7
+ import { MyazaText } from '../components/Typography';
8
+ import { useTheme } from '../components/runtime';
9
+ import { radius } from '../config/theme';
10
+ import type { BusinessSearchHit } from '../services/api';
11
+
12
+ // The result list: count first ("40 results" says whether to narrow before you
13
+ // start reading), a filter that narrows without re-searching, and one row per
14
+ // candidate. Split from BusinessSearch (200-line rule). Every dimension here —
15
+ // 40px filter, 12px row padding, 6px row gaps, 256px list — is the web SDK's,
16
+ // so the two render the same screen.
17
+
18
+ export function BusinessSearchResults({
19
+ country,
20
+ hits,
21
+ shown,
22
+ truncated,
23
+ filter,
24
+ onFilter,
25
+ onPicked,
26
+ }: {
27
+ country: string;
28
+ hits: BusinessSearchHit[];
29
+ shown: BusinessSearchHit[];
30
+ truncated: boolean;
31
+ filter: string;
32
+ onFilter: (value: string) => void;
33
+ onPicked: (hit: BusinessSearchHit) => void;
34
+ }): React.ReactElement {
35
+ const { colors } = useTheme();
36
+ return (
37
+ <>
38
+ {/* Count first: "40 results" says whether to narrow before you start
39
+ reading, which a bare list does not. */}
40
+ <View
41
+ style={{
42
+ flexDirection: 'row',
43
+ alignItems: 'center',
44
+ justifyContent: 'space-between',
45
+ gap: 12,
46
+ marginTop: 12,
47
+ }}
48
+ >
49
+ <MyazaText variant="bodyMedium" color={colors.textSecondary}>
50
+ {shown.length === hits.length
51
+ ? `${hits.length} results`
52
+ : `${shown.length} of ${hits.length} results`}
53
+ </MyazaText>
54
+ <View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
55
+ <MyazaText variant="bodyMedium" color={colors.textSecondary}>
56
+ Filter
57
+ </MyazaText>
58
+ <View style={{ width: 176 }}>
59
+ <MyazaInput
60
+ value={filter}
61
+ onChangeText={onFilter}
62
+ placeholder="Name or number"
63
+ autoCorrect={false}
64
+ height={40}
65
+ fontSize={14}
66
+ prefix={<Icon name="filter" size={14} color={colors.textMuted} />}
67
+ />
68
+ </View>
69
+ </View>
70
+ </View>
71
+ <ScrollView style={{ maxHeight: 256, marginTop: 12 }} nestedScrollEnabled>
72
+ {shown.map((hit) => (
73
+ <Pressable
74
+ key={`${hit.registrationNumber}-${hit.name}`}
75
+ accessibilityRole="button"
76
+ onPress={() => onPicked(hit)}
77
+ style={{
78
+ flexDirection: 'row',
79
+ alignItems: 'center',
80
+ gap: 12,
81
+ borderWidth: 1,
82
+ borderColor: colors.border,
83
+ borderRadius: radius.sm,
84
+ padding: 12,
85
+ marginBottom: 6,
86
+ }}
87
+ >
88
+ <Icon name="building-2" size={16} color={colors.textSecondary} />
89
+ <View style={{ flex: 1, minWidth: 0 }}>
90
+ <MyazaText variant="label" color={colors.textDark} numberOfLines={1}>
91
+ {hit.name}
92
+ </MyazaText>
93
+ <View style={{ flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 2 }}>
94
+ <CountryFlag country={country} size={14} />
95
+ <MyazaText variant="bodySmall" color={colors.textSecondary}>
96
+ {hit.registrationNumber}
97
+ </MyazaText>
98
+ </View>
99
+ </View>
100
+ </Pressable>
101
+ ))}
102
+ </ScrollView>
103
+ {shown.length === 0 && (
104
+ <MyazaText variant="bodyMedium" color={colors.textSecondary} style={{ marginTop: 12 }}>
105
+ Nothing here matches "{filter.trim()}". Clear the filter to see all {hits.length}.
106
+ </MyazaText>
107
+ )}
108
+ {truncated && (
109
+ <MyazaText variant="bodySmall" color={colors.textSecondary} style={{ marginTop: 6 }}>
110
+ Showing the first {hits.length}. Add more of the name to narrow it down.
111
+ </MyazaText>
112
+ )}
113
+ </>
114
+ );
115
+ }
@@ -5,79 +5,119 @@ import { spacing } from '../config/theme';
5
5
  import { useTheme } from '../components/runtime';
6
6
  import { MyazaText } from '../components/Typography';
7
7
  import { MyazaInput } from '../components/MyazaInput';
8
+ import { MyazaDateField } from '../components/MyazaDateField';
9
+ import { PhoneNumberInput } from '../components/PhoneNumberInput';
10
+ import { isValidContactEmail } from '../config/contact';
11
+ import { isValidWebsite } from '../config/website';
8
12
  import type { CompanyInfoField, CompanyInfoMode } from '../types/business';
13
+ import type { BusinessState } from '../store/state';
9
14
 
10
15
  // ---------------------------------------------------------------------------
11
16
  // The company profile a KYB workflow asks for.
12
17
  //
13
18
  // Split from BusinessDetailsStep (200-line rule). Each field's mode comes from
14
19
  // the workflow and must match the server's resolution exactly — a field marked
15
- // optional here but required there produces a 422 the user cannot act on.
16
- // Section header, captions and placeholders mirror the web SDK's
17
- // BusinessCompanyInfoFields 1:1.
20
+ // optional here but required there produces a 422 the user cannot act on. The
21
+ // last five are registry facts the applicant STATES: asked as their own answer
22
+ // rather than filled from the register, because where the two differ that is
23
+ // the finding. Field list, labels, placeholders and controls mirror the web
24
+ // SDK's BusinessCompanyInfoFields 1:1.
18
25
  // ---------------------------------------------------------------------------
19
26
 
20
- export const COMPANY_FIELD_LABELS: Record<CompanyInfoField, string> = {
21
- address: 'Registered address',
22
- email: 'Business email',
23
- phone: 'Business phone',
24
- website: 'Website',
25
- };
26
-
27
- const COMPANY_FIELD_PLACEHOLDERS: Record<CompanyInfoField, string> = {
28
- address: 'e.g. 12 Marina Road, Lagos',
29
- email: 'hello@company.com',
30
- phone: '+234 800 000 0000',
31
- website: 'company.com',
32
- };
27
+ const FIELD_DEFS: Array<{
28
+ key: CompanyInfoField;
29
+ label: string;
30
+ placeholder: string;
31
+ kind?: 'phone' | 'date' | 'email' | 'website';
32
+ }> = [
33
+ { key: 'address', label: 'Registered address', placeholder: 'e.g. 12 Marina Road, Lagos' },
34
+ { key: 'email', label: 'Business email', placeholder: 'hello@company.com', kind: 'email' },
35
+ { key: 'phone', label: 'Business phone', placeholder: '+234 800 000 0000', kind: 'phone' },
36
+ { key: 'website', label: 'Website', placeholder: 'company.com', kind: 'website' },
37
+ { key: 'dateOfIncorporation', label: 'Date of incorporation', placeholder: 'YYYY-MM-DD', kind: 'date' },
38
+ { key: 'taxId', label: 'Tax ID', placeholder: 'e.g. 01234567-0001' },
39
+ { key: 'vatNumber', label: 'VAT number', placeholder: 'e.g. NG123456789' },
40
+ { key: 'companyType', label: 'Company type', placeholder: 'e.g. Private Limited Company' },
41
+ { key: 'natureOfBusiness', label: 'Nature of business', placeholder: 'What the company does' },
42
+ ];
33
43
 
34
44
  export function CompanyInfoFields({
35
- fields,
36
- modes,
37
45
  values,
38
- missing,
39
- showErrors,
46
+ modes,
47
+ country,
48
+ geoCountry,
40
49
  onChange,
41
50
  }: {
42
- fields: CompanyInfoField[];
51
+ values: BusinessState;
43
52
  modes: Record<CompanyInfoField, CompanyInfoMode>;
44
- values: Record<CompanyInfoField, string>;
45
- missing: CompanyInfoField[];
46
- showErrors: boolean;
53
+ /** Seeds the phone dial code: the company's country of registration. */
54
+ country?: string;
55
+ /** The visitor's IP country: the dial-code seed of last resort, and the
56
+ * row pinned to the top of the picker. */
57
+ geoCountry?: string | null;
47
58
  onChange: (field: CompanyInfoField, value: string) => void;
48
59
  }): React.ReactElement | null {
49
60
  const { colors } = useTheme();
50
- if (fields.length === 0) return null;
61
+ const visible = FIELD_DEFS.filter((f) => modes[f.key] !== 'off');
62
+ if (visible.length === 0) return null;
51
63
 
52
64
  return (
53
- <>
54
- <View style={{ height: spacing.md }} />
55
- <MyazaText variant="bodyMedium" style={{ fontWeight: '600' }}>
56
- Company information
57
- </MyazaText>
65
+ <View>
66
+ <MyazaText variant="label">Company information</MyazaText>
58
67
  <MyazaText variant="bodySmall" color={colors.textSecondary} style={{ marginTop: 2 }}>
59
68
  We verify these details against the official registry record.
60
69
  </MyazaText>
61
- {fields.map((field) => (
62
- <View key={field}>
63
- <View style={{ height: spacing.sm }} />
64
- <MyazaInput
65
- label={`${COMPANY_FIELD_LABELS[field]}${modes[field] === 'required' ? ' *' : ' (optional)'}`}
66
- value={values[field]}
67
- onChangeText={(text) => onChange(field, text)}
68
- placeholder={COMPANY_FIELD_PLACEHOLDERS[field]}
69
- keyboardType={
70
- field === 'email' ? 'email-address' : field === 'phone' ? 'phone-pad' : 'default'
71
- }
72
- autoCapitalize="none"
73
- error={
74
- showErrors && missing.includes(field)
75
- ? `${COMPANY_FIELD_LABELS[field]} is required.`
76
- : null
77
- }
78
- />
79
- </View>
80
- ))}
81
- </>
70
+ {visible.map((f) => {
71
+ const required = modes[f.key] === 'required';
72
+ const value = values[f.key];
73
+ const error =
74
+ f.kind === 'email' && value !== '' && !isValidContactEmail(value)
75
+ ? 'Enter a valid email address.'
76
+ : f.kind === 'website' && value !== '' && !isValidWebsite(value)
77
+ ? 'Enter a valid website, for example company.com'
78
+ : null;
79
+ return (
80
+ <View key={f.key} style={{ marginTop: spacing.md }}>
81
+ {f.kind === 'phone' || f.kind === 'date' ? (
82
+ <MyazaText variant="label" style={{ marginBottom: spacing.sm }}>
83
+ {f.label}
84
+ {required ? ' *' : ' (optional)'}
85
+ </MyazaText>
86
+ ) : null}
87
+ {f.kind === 'phone' ? (
88
+ // The same control the phone-verification step uses: dial-code
89
+ // picker, as-you-type national formatting, E.164 out. A business
90
+ // number is a phone number, and a bare text box gets back a dozen
91
+ // different shapes of the same digits. `value` carries the
92
+ // register's number when the lookup returned one.
93
+ <PhoneNumberInput
94
+ value={value}
95
+ defaultCountry={country || geoCountry || undefined}
96
+ geoCountry={geoCountry}
97
+ onChange={({ e164 }) => onChange('phone', e164)}
98
+ />
99
+ ) : f.kind === 'date' ? (
100
+ // A date gets the picker, not a text box — same control the
101
+ // questionnaire uses; emits strict YYYY-MM-DD.
102
+ <MyazaDateField
103
+ value={value || undefined}
104
+ placeholder={f.placeholder}
105
+ onChange={(iso) => onChange(f.key, iso)}
106
+ />
107
+ ) : (
108
+ <MyazaInput
109
+ label={`${f.label}${required ? ' *' : ' (optional)'}`}
110
+ value={value}
111
+ onChangeText={(text) => onChange(f.key, text)}
112
+ placeholder={f.placeholder}
113
+ keyboardType={f.kind === 'email' ? 'email-address' : 'default'}
114
+ autoCapitalize={f.kind === 'email' || f.kind === 'website' ? 'none' : 'words'}
115
+ error={error}
116
+ />
117
+ )}
118
+ </View>
119
+ );
120
+ })}
121
+ </View>
82
122
  );
83
123
  }
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
  import { Linking, Pressable, View } from 'react-native';
3
- import Svg, { Circle, Defs, LinearGradient, Stop } from 'react-native-svg';
3
+ import Svg, { Circle } from 'react-native-svg';
4
4
 
5
5
  import { radius, spacing } from '../config/theme';
6
6
  import { PRIVACY_URL, TERMS_URL } from '../config/brand';
@@ -9,6 +9,7 @@ import { MyazaText } from '../components/Typography';
9
9
  import { MyazaButton } from '../components/MyazaButton';
10
10
  import { Icon } from '../components/Icon';
11
11
  import { buildConsentModel } from './consent/model';
12
+ import { resubmitNote } from '../lib/resubmit';
12
13
 
13
14
  // Consent / welcome screen — 1:1 with the web SDK's ConsentStep and the
14
15
  // Flutter SDK's ConsentScreen: shield hero, token-filled greeting, a "DURING
@@ -27,10 +28,40 @@ export function ConsentStep(): React.ReactElement {
27
28
  const { isBusiness, title, description, steps, capturesFace, recordsVideo } =
28
29
  buildConsentModel(config);
29
30
 
31
+ // A reviewer sent this applicant back. Say so, and say why — the note is the
32
+ // only thing on screen that explains a flow which has silently lost most of
33
+ // its steps. Above the hero so it is read before the instructions.
34
+ const redoNote = resubmitNote(config.resubmit);
35
+
30
36
  return (
31
37
  <View>
32
38
  <View style={{ height: spacing.sm }} />
33
39
 
40
+ {redoNote ? (
41
+ <View
42
+ style={{
43
+ flexDirection: 'row',
44
+ gap: spacing.sm,
45
+ borderRadius: radius.lg,
46
+ borderWidth: 1,
47
+ borderColor: `${colors.warning}66`,
48
+ backgroundColor: colors.warningBg,
49
+ padding: spacing.md,
50
+ marginBottom: spacing.lg,
51
+ }}
52
+ >
53
+ <Icon name="refresh" size={16} color={colors.warning} />
54
+ <View style={{ flex: 1, gap: 2 }}>
55
+ <MyazaText variant="bodyMedium" color={colors.warning}>
56
+ A few things to redo
57
+ </MyazaText>
58
+ <MyazaText variant="bodySmall" color={colors.textSecondary}>
59
+ {redoNote}
60
+ </MyazaText>
61
+ </View>
62
+ </View>
63
+ ) : null}
64
+
34
65
  {/* Shield hero — concentric tinted rings + primary badge */}
35
66
  <View style={{ alignItems: 'center' }}>
36
67
  <View style={{ width: 80, height: 80, alignItems: 'center', justifyContent: 'center' }}>
@@ -50,21 +81,10 @@ export function ConsentStep(): React.ReactElement {
50
81
  elevation: 6,
51
82
  }}
52
83
  >
53
- {/* Top-left → bottom-right gradient badge, matching Flutter. A flat
54
- fill was the last visual difference on this screen. The second
55
- stop is the SAME primary at 70% opacity rather than a
56
- pre-blended colour: composited over the sheet it produces
57
- Flutter's `alphaBlend(primary@0.7, background)` exactly, and it
58
- keeps working when the consumer overrides primaryColor or the
59
- theme flips, which a hardcoded blend would not. */}
84
+ {/* Solid primary badge — no gradients in the flow (house rule
85
+ 2026-08-29, applied across all three SDKs in lockstep). */}
60
86
  <Svg width={56} height={56} style={{ position: 'absolute' }}>
61
- <Defs>
62
- <LinearGradient id="myazaShieldHero" x1="0" y1="0" x2="1" y2="1">
63
- <Stop offset="0" stopColor={colors.primary} stopOpacity={1} />
64
- <Stop offset="1" stopColor={colors.primary} stopOpacity={0.7} />
65
- </LinearGradient>
66
- </Defs>
67
- <Circle cx={28} cy={28} r={28} fill="url(#myazaShieldHero)" />
87
+ <Circle cx={28} cy={28} r={28} fill={colors.primary} />
68
88
  </Svg>
69
89
  <Icon name="shield" size={28} color={colors.onPrimary} />
70
90
  </View>
@@ -122,7 +142,7 @@ export function ConsentStep(): React.ReactElement {
122
142
  <MyazaText
123
143
  variant="bodySmall"
124
144
  color={colors.textDark}
125
- style={{ fontWeight: '600', textDecorationLine: 'underline' }}
145
+ style={{ fontWeight: '500', textDecorationLine: 'underline' }}
126
146
  onPress={() => void Linking.openURL(TERMS_URL).catch(() => undefined)}
127
147
  >
128
148
  End User Terms
@@ -131,7 +151,7 @@ export function ConsentStep(): React.ReactElement {
131
151
  <MyazaText
132
152
  variant="bodySmall"
133
153
  color={colors.textDark}
134
- style={{ fontWeight: '600', textDecorationLine: 'underline' }}
154
+ style={{ fontWeight: '500', textDecorationLine: 'underline' }}
135
155
  onPress={() => void Linking.openURL(PRIVACY_URL).catch(() => undefined)}
136
156
  >
137
157
  Privacy Policy
@@ -0,0 +1,54 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+
4
+ import { spacing } from '../config/theme';
5
+ import { MyazaButton } from '../components/MyazaButton';
6
+ import { ContactFooterNote } from './ContactFooterNote';
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // The contact step's footer: the one primary action (send, then verify), the
10
+ // optional skip, and the reassurance note. Split from ContactVerificationStep
11
+ // (200-line rule); the step owns every decision and this only renders them.
12
+ // ---------------------------------------------------------------------------
13
+
14
+ export function ContactActions({
15
+ hasChallenge,
16
+ busy,
17
+ disabled,
18
+ required,
19
+ isEmail,
20
+ onPrimary,
21
+ onSkip,
22
+ }: {
23
+ /** A code is out: the button verifies instead of sending. */
24
+ hasChallenge: boolean;
25
+ busy: boolean;
26
+ disabled: boolean;
27
+ /** `required: false` adds the skip; the server enforces the rest. */
28
+ required: boolean;
29
+ isEmail: boolean;
30
+ onPrimary: () => void;
31
+ onSkip: () => void;
32
+ }): React.ReactElement {
33
+ return (
34
+ <>
35
+ <View style={{ height: spacing.md }} />
36
+ <MyazaButton
37
+ label={hasChallenge ? 'Verify code' : 'Send code'}
38
+ loading={busy}
39
+ disabled={disabled}
40
+ onPress={onPrimary}
41
+ />
42
+
43
+ {!required ? (
44
+ <>
45
+ <View style={{ height: spacing.sm }} />
46
+ <MyazaButton label="Skip for now" variant="ghost" disabled={busy} onPress={onSkip} />
47
+ </>
48
+ ) : null}
49
+
50
+ <View style={{ height: spacing.md }} />
51
+ <ContactFooterNote isEmail={isEmail} />
52
+ </>
53
+ );
54
+ }
@@ -18,6 +18,7 @@ export function ContactDestinationField({
18
18
  onEmailChange,
19
19
  onPhoneChange,
20
20
  defaultCountry,
21
+ geoCountry,
21
22
  error,
22
23
  disabled,
23
24
  }: {
@@ -26,6 +27,8 @@ export function ContactDestinationField({
26
27
  onEmailChange: (value: string) => void;
27
28
  onPhoneChange: (value: { e164: string; isValid: boolean }) => void;
28
29
  defaultCountry?: string;
30
+ /** The visitor's IP country, pinned to the top of the dial-code picker. */
31
+ geoCountry?: string | null;
29
32
  error: string | null;
30
33
  disabled?: boolean;
31
34
  }): React.ReactElement {
@@ -52,7 +55,9 @@ export function ContactDestinationField({
52
55
  </MyazaText>
53
56
  <PhoneNumberInput
54
57
  defaultCountry={defaultCountry}
58
+ geoCountry={geoCountry}
55
59
  disabled={disabled}
60
+ autoFocus
56
61
  onChange={onPhoneChange}
57
62
  />
58
63
  {error ? <ErrorLine text={error} /> : null}