@myazahq/kyc-sdk-react-native 2.4.0 → 2.5.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.
- package/android/build.gradle +35 -3
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +75 -0
- package/ios/HybridMyazaFaceDetector.swift +8 -0
- package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.cpp +23 -0
- package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.hpp +2 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaFaceDetectorSpec.kt +9 -0
- package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.cpp +8 -0
- package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.hpp +43 -0
- package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.hpp +17 -0
- package/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec.swift +2 -0
- package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec_cxx.swift +31 -0
- package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.cpp +2 -0
- package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.hpp +3 -0
- package/package.json +1 -1
- package/src/MyazaKYC.tsx +12 -0
- package/src/components/DashedBorder.tsx +55 -0
- package/src/components/DialCodePicker.tsx +14 -7
- package/src/components/Icon.tsx +9 -0
- package/src/components/MultiIdProgress.tsx +107 -0
- package/src/components/MyazaInput.tsx +23 -4
- package/src/components/MyazaSelect.tsx +2 -1
- package/src/components/OwnershipSlider.tsx +102 -0
- package/src/components/PhoneNumberInput.tsx +37 -4
- package/src/components/StaggerIn.tsx +63 -0
- package/src/components/StepView.tsx +26 -0
- package/src/components/glass/FloatingSheet.tsx +73 -21
- package/src/components/glass/GlassSheet.tsx +14 -2
- package/src/components/stepHeaderMeta.tsx +3 -1
- package/src/config/business.ts +6 -6
- package/src/config/businessDetailsValidity.ts +52 -0
- package/src/config/businessPrefill.ts +72 -0
- package/src/config/businessSteps.ts +14 -3
- package/src/config/keyPeople.ts +111 -7
- package/src/config/keyPeopleCorporate.ts +19 -0
- package/src/config/keyPeopleOwnerTypes.ts +12 -0
- package/src/config/keyPeopleOwners.ts +26 -0
- package/src/config/keyPeoplePrefill.ts +138 -0
- package/src/config/keyPeopleSectionDefs.ts +65 -0
- package/src/config/keyPeopleSections.ts +167 -0
- package/src/config/registrationHint.ts +1 -1
- package/src/config/stepOrder.ts +24 -4
- package/src/config/theme.ts +8 -2
- package/src/config/website.ts +36 -0
- package/src/config/workflowMerge.ts +5 -0
- package/src/emrtd/activeAuth.ts +100 -0
- package/src/emrtd/extras.ts +76 -0
- package/src/emrtd/files.ts +5 -0
- package/src/emrtd/index.ts +1 -0
- package/src/emrtd/read.ts +8 -1
- package/src/emrtd/session.ts +41 -0
- package/src/emrtd/stages.ts +11 -0
- package/src/lib/contact-recovery.ts +44 -0
- package/src/lib/multi-id.ts +210 -0
- package/src/lib/resubmit.ts +130 -0
- package/src/lib/screen-corners.ts +50 -0
- package/src/lib/step-log.ts +24 -3
- package/src/lib/use-multi-id-plan.ts +29 -0
- package/src/liveness/useModelReady.ts +65 -0
- package/src/liveness/visionCameraFaceDetector.ts +35 -0
- package/src/screens/ApplicantRoleStep.tsx +9 -5
- package/src/screens/BusinessCheckPanel.tsx +89 -0
- package/src/screens/BusinessDetailsFields.tsx +109 -0
- package/src/screens/BusinessDetailsStep.tsx +131 -145
- package/src/screens/BusinessDocumentSlot.tsx +2 -3
- package/src/screens/BusinessDocumentsStep.tsx +1 -1
- package/src/screens/BusinessKeyPeopleStep.tsx +103 -71
- package/src/screens/BusinessPickedCard.tsx +86 -0
- package/src/screens/BusinessPickedSection.tsx +51 -0
- package/src/screens/BusinessRegistryPickers.tsx +67 -0
- package/src/screens/BusinessSandboxToggle.tsx +114 -0
- package/src/screens/BusinessSearch.tsx +199 -0
- package/src/screens/BusinessSearchResults.tsx +115 -0
- package/src/screens/CompanyInfoFields.tsx +86 -51
- package/src/screens/ConsentStep.tsx +31 -0
- package/src/screens/ContactDestinationField.tsx +1 -0
- package/src/screens/ContactVerificationStep.tsx +34 -3
- package/src/screens/IdTypeStep.tsx +12 -5
- package/src/screens/KeepLinksSheet.tsx +88 -0
- package/src/screens/KeyPeopleAwaitCard.tsx +204 -0
- package/src/screens/KeyPeopleAwaitList.tsx +126 -0
- package/src/screens/KeyPeoplePending.tsx +154 -0
- package/src/screens/KeyPeopleSection.tsx +162 -0
- package/src/screens/KeyPeopleSectionsList.tsx +103 -0
- package/src/screens/KeyPeopleUboExemption.tsx +72 -0
- package/src/screens/KeyPersonCard.tsx +36 -4
- package/src/screens/KeyPersonForm.tsx +195 -36
- package/src/screens/KeyPersonKindToggle.tsx +68 -0
- package/src/screens/KeyPersonOwners.tsx +109 -0
- package/src/screens/KeyPersonRoleChips.tsx +68 -0
- package/src/screens/KeyPersonSheet.tsx +92 -108
- package/src/screens/LivenessStep.tsx +18 -0
- package/src/screens/NfcStep.tsx +14 -0
- package/src/screens/ProofOfAddressStep.tsx +6 -3
- package/src/screens/SubmittedStep.tsx +66 -10
- package/src/screens/nfc/NfcReadProgress.tsx +1 -0
- package/src/screens/useAwaitingPeople.ts +90 -0
- package/src/services/api-types.ts +158 -2
- package/src/services/api-verify-types.ts +53 -1
- package/src/services/api.ts +104 -0
- package/src/services/deviceMetadata.ts +1 -1
- package/src/services/errors.ts +10 -2
- package/src/services/workflowGate.ts +1 -0
- package/src/specs/MyazaFaceDetector.nitro.ts +29 -0
- package/src/store/businessCheck.ts +113 -0
- package/src/store/derive.ts +15 -0
- package/src/store/kycStore.ts +241 -14
- package/src/store/serverConfig.ts +2 -0
- package/src/store/session.ts +208 -0
- package/src/store/state.ts +155 -2
- package/src/store/submit.ts +63 -13
- package/src/store/submitApplicant.ts +5 -11
- package/src/types/business.ts +23 -2
- package/src/types/config.ts +22 -1
- package/src/types/verification.ts +1 -1
- package/src/types/workflow.ts +15 -0
- 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,114 @@ 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
|
-
//
|
|
17
|
-
//
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
39
|
-
|
|
46
|
+
modes,
|
|
47
|
+
country,
|
|
40
48
|
onChange,
|
|
41
49
|
}: {
|
|
42
|
-
|
|
50
|
+
values: BusinessState;
|
|
43
51
|
modes: Record<CompanyInfoField, CompanyInfoMode>;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
showErrors: boolean;
|
|
52
|
+
/** Seeds the phone dial code: the company's country of registration. */
|
|
53
|
+
country?: string;
|
|
47
54
|
onChange: (field: CompanyInfoField, value: string) => void;
|
|
48
55
|
}): React.ReactElement | null {
|
|
49
56
|
const { colors } = useTheme();
|
|
50
|
-
|
|
57
|
+
const visible = FIELD_DEFS.filter((f) => modes[f.key] !== 'off');
|
|
58
|
+
if (visible.length === 0) return null;
|
|
51
59
|
|
|
52
60
|
return (
|
|
53
|
-
|
|
54
|
-
<
|
|
55
|
-
<MyazaText variant="bodyMedium" style={{ fontWeight: '600' }}>
|
|
56
|
-
Company information
|
|
57
|
-
</MyazaText>
|
|
61
|
+
<View>
|
|
62
|
+
<MyazaText variant="label">Company information</MyazaText>
|
|
58
63
|
<MyazaText variant="bodySmall" color={colors.textSecondary} style={{ marginTop: 2 }}>
|
|
59
64
|
We verify these details against the official registry record.
|
|
60
65
|
</MyazaText>
|
|
61
|
-
{
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
?
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
66
|
+
{visible.map((f) => {
|
|
67
|
+
const required = modes[f.key] === 'required';
|
|
68
|
+
const value = values[f.key];
|
|
69
|
+
const error =
|
|
70
|
+
f.kind === 'email' && value !== '' && !isValidContactEmail(value)
|
|
71
|
+
? 'Enter a valid email address.'
|
|
72
|
+
: f.kind === 'website' && value !== '' && !isValidWebsite(value)
|
|
73
|
+
? 'Enter a valid website, for example company.com'
|
|
74
|
+
: null;
|
|
75
|
+
return (
|
|
76
|
+
<View key={f.key} style={{ marginTop: spacing.md }}>
|
|
77
|
+
{f.kind === 'phone' || f.kind === 'date' ? (
|
|
78
|
+
<MyazaText variant="label" style={{ marginBottom: spacing.sm }}>
|
|
79
|
+
{f.label}
|
|
80
|
+
{required ? ' *' : ' (optional)'}
|
|
81
|
+
</MyazaText>
|
|
82
|
+
) : null}
|
|
83
|
+
{f.kind === 'phone' ? (
|
|
84
|
+
// The same control the phone-verification step uses: dial-code
|
|
85
|
+
// picker, as-you-type national formatting, E.164 out. A business
|
|
86
|
+
// number is a phone number, and a bare text box gets back a dozen
|
|
87
|
+
// different shapes of the same digits. `value` carries the
|
|
88
|
+
// register's number when the lookup returned one.
|
|
89
|
+
<PhoneNumberInput
|
|
90
|
+
value={value}
|
|
91
|
+
defaultCountry={country}
|
|
92
|
+
onChange={({ e164 }) => onChange('phone', e164)}
|
|
93
|
+
/>
|
|
94
|
+
) : f.kind === 'date' ? (
|
|
95
|
+
// A date gets the picker, not a text box — same control the
|
|
96
|
+
// questionnaire uses; emits strict YYYY-MM-DD.
|
|
97
|
+
<MyazaDateField
|
|
98
|
+
value={value || undefined}
|
|
99
|
+
placeholder={f.placeholder}
|
|
100
|
+
onChange={(iso) => onChange(f.key, iso)}
|
|
101
|
+
/>
|
|
102
|
+
) : (
|
|
103
|
+
<MyazaInput
|
|
104
|
+
label={`${f.label}${required ? ' *' : ' (optional)'}`}
|
|
105
|
+
value={value}
|
|
106
|
+
onChangeText={(text) => onChange(f.key, text)}
|
|
107
|
+
placeholder={f.placeholder}
|
|
108
|
+
keyboardType={f.kind === 'email' ? 'email-address' : 'default'}
|
|
109
|
+
autoCapitalize={f.kind === 'email' || f.kind === 'website' ? 'none' : 'words'}
|
|
110
|
+
error={error}
|
|
111
|
+
/>
|
|
112
|
+
)}
|
|
113
|
+
</View>
|
|
114
|
+
);
|
|
115
|
+
})}
|
|
116
|
+
</View>
|
|
82
117
|
);
|
|
83
118
|
}
|
|
@@ -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' }}>
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import React, { useCallback, useEffect, useState } from 'react';
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
2
|
import { View } from 'react-native';
|
|
3
3
|
|
|
4
4
|
import { spacing } from '../config/theme';
|
|
5
5
|
import { useEffectiveCountry, useKyc, useKycConfig, useKycStore } from '../components/runtime';
|
|
6
6
|
import { MyazaButton } from '../components/MyazaButton';
|
|
7
|
+
import { MyazaAlert } from '../components/MyazaAlert';
|
|
7
8
|
import { ContactDestinationField } from './ContactDestinationField';
|
|
8
9
|
import { ContactVerifiedPanel } from './ContactVerifiedPanel';
|
|
9
10
|
import { ContactCodeStep } from './ContactCodeStep';
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
type PhoneOtpChannel,
|
|
18
19
|
} from '../config/contact';
|
|
19
20
|
import { attemptsRemaining, describeCheckError, describeSendError } from '../services/contactErrors';
|
|
21
|
+
import { stepAfterContactVerified } from '../lib/contact-recovery';
|
|
20
22
|
|
|
21
23
|
// ---------------------------------------------------------------------------
|
|
22
24
|
// Contact-verification OTP step — email or phone, one component, two mounts.
|
|
@@ -37,6 +39,7 @@ export function ContactVerificationStep({
|
|
|
37
39
|
const country = useEffectiveCountry();
|
|
38
40
|
const store = useKycStore();
|
|
39
41
|
const contact = useKyc((s) => s.contact);
|
|
42
|
+
const serverConfig = useKyc((s) => s.serverConfig);
|
|
40
43
|
|
|
41
44
|
const isEmail = channel === 'email';
|
|
42
45
|
const stepConfig = isEmail ? config.emailVerification : config.phoneVerification;
|
|
@@ -69,7 +72,20 @@ export function ContactVerificationStep({
|
|
|
69
72
|
const value = isEmail ? destination.trim() : phone.e164;
|
|
70
73
|
const canSend = isEmail ? isValidContactEmail(value) : phone.isValid;
|
|
71
74
|
|
|
72
|
-
|
|
75
|
+
// Entered via submit recovery? (The server refused this channel's proof at
|
|
76
|
+
// submit — the token had expired or was already claimed.) Captured at mount:
|
|
77
|
+
// verifying clears the flag, and Continue must still route back to
|
|
78
|
+
// 'submitted' afterwards (which auto-submits with the fresh proof).
|
|
79
|
+
const recovery = useRef((contact.expired ?? []).includes(channel)).current;
|
|
80
|
+
const advance = useCallback(() => {
|
|
81
|
+
const target = stepAfterContactVerified({
|
|
82
|
+
recovery,
|
|
83
|
+
expired: store.getState().contact.expired ?? [],
|
|
84
|
+
channel,
|
|
85
|
+
});
|
|
86
|
+
if (target) store.getState().goToStep(target);
|
|
87
|
+
else store.getState().nextStep();
|
|
88
|
+
}, [store, channel, recovery]);
|
|
73
89
|
|
|
74
90
|
// The sheet header is rendered by the shell and cannot see this step's state,
|
|
75
91
|
// so publish what it needs to caption: the channel in play now, and the
|
|
@@ -159,12 +175,27 @@ export function ContactVerificationStep({
|
|
|
159
175
|
/>
|
|
160
176
|
) : (
|
|
161
177
|
<>
|
|
178
|
+
{recovery && (
|
|
179
|
+
<>
|
|
180
|
+
<MyazaAlert
|
|
181
|
+
variant="warning"
|
|
182
|
+
title="Please verify again"
|
|
183
|
+
message={`Your earlier confirmation has expired, so please verify ${isEmail ? 'your email' : 'your number'} once more. Everything else is saved, and we will submit again straight after.`}
|
|
184
|
+
/>
|
|
185
|
+
<View style={{ height: spacing.md }} />
|
|
186
|
+
</>
|
|
187
|
+
)}
|
|
162
188
|
<ContactDestinationField
|
|
163
189
|
isEmail={isEmail}
|
|
164
190
|
email={destination}
|
|
165
191
|
onEmailChange={setDestination}
|
|
166
192
|
onPhoneChange={setPhone}
|
|
167
|
-
defaultCountry={
|
|
193
|
+
defaultCountry={
|
|
194
|
+
config.phoneVerification?.defaultCountry ??
|
|
195
|
+
country ??
|
|
196
|
+
serverConfig.geoCountry ??
|
|
197
|
+
undefined
|
|
198
|
+
}
|
|
168
199
|
error={error}
|
|
169
200
|
disabled={busy}
|
|
170
201
|
/>
|
|
@@ -3,6 +3,7 @@ import { Pressable, View } from 'react-native';
|
|
|
3
3
|
|
|
4
4
|
import { radius, spacing } from '../config/theme';
|
|
5
5
|
import { ID_TYPES, resolveIdTypeDefinition } from '../config/idTypes';
|
|
6
|
+
import { useMultiIdPlan } from '../lib/use-multi-id-plan';
|
|
6
7
|
import type { IdType, IdTypeDefinition } from '../types/config';
|
|
7
8
|
import {
|
|
8
9
|
useEffectiveCountry,
|
|
@@ -54,6 +55,10 @@ export function IdTypeStep(): React.ReactElement {
|
|
|
54
55
|
const store = useKycStore();
|
|
55
56
|
const serverConfig = useKyc((s) => s.serverConfig);
|
|
56
57
|
const selectedIdType = useKyc((s) => s.selectedIdType);
|
|
58
|
+
// Multi-ID: this picker is for ONE check of several. It may only offer picks
|
|
59
|
+
// that leave every later check something to offer — the server validates the
|
|
60
|
+
// same rule, so an unsafe pick would be a submission it rejects.
|
|
61
|
+
const plan = useMultiIdPlan();
|
|
57
62
|
|
|
58
63
|
// Select AND advance in one tap. Both go through the store so `nextStep`
|
|
59
64
|
// routes off the value just written — a document ID to document-capture, a
|
|
@@ -104,13 +109,15 @@ export function IdTypeStep(): React.ReactElement {
|
|
|
104
109
|
// what makes the disabled step actually disappear from the live flow rather
|
|
105
110
|
// than still offering passports and licences that then have nowhere to be
|
|
106
111
|
// captured. Mirrors the web SDK's IdTypeStep.
|
|
107
|
-
const visible = useMemo<IdTypeDefinition[]>(
|
|
108
|
-
|
|
112
|
+
const visible = useMemo<IdTypeDefinition[]>(() => {
|
|
113
|
+
const base =
|
|
109
114
|
config.enableDocumentCapture === false
|
|
110
115
|
? available.filter((t) => !t.requiresDocumentCapture)
|
|
111
|
-
: available
|
|
112
|
-
|
|
113
|
-
|
|
116
|
+
: available;
|
|
117
|
+
// Multi-ID: only the picks that are still free AND leave every later check
|
|
118
|
+
// an option. An ID already used earlier in the run is simply not offered.
|
|
119
|
+
return plan ? base.filter((t) => plan.safeOptions.includes(t.key)) : base;
|
|
120
|
+
}, [available, config.enableDocumentCapture, plan]);
|
|
114
121
|
|
|
115
122
|
if (serverConfig.status === 'loading') {
|
|
116
123
|
return (
|