@myazahq/kyc-sdk-react-native 2.3.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 -2
- package/src/MyazaKYC.tsx +12 -0
- package/src/components/BrandBar.tsx +137 -0
- package/src/components/DashedBorder.tsx +55 -0
- package/src/components/DialCodePicker.tsx +25 -24
- package/src/components/DocumentReviewSide.tsx +34 -14
- package/src/components/Icon.tsx +14 -0
- package/src/components/KycSheet.tsx +39 -140
- package/src/components/MediaSourceSheet.tsx +7 -37
- package/src/components/MultiIdProgress.tsx +107 -0
- package/src/components/MyazaDateField.tsx +6 -20
- package/src/components/MyazaInput.tsx +23 -4
- package/src/components/MyazaSelect.tsx +17 -40
- package/src/components/OwnershipSlider.tsx +102 -0
- package/src/components/PhoneNumberInput.tsx +37 -4
- package/src/components/SandboxBanner.tsx +92 -0
- package/src/components/StaggerIn.tsx +63 -0
- package/src/components/StepHeader.tsx +15 -2
- package/src/components/StepView.tsx +26 -0
- package/src/components/glass/ChromeGlass.tsx +65 -0
- package/src/components/glass/FloatingSheet.tsx +242 -0
- package/src/components/glass/GlassSheet.tsx +50 -0
- package/src/components/glass/GlassSurface.tsx +31 -3
- package/src/components/stepHeaderMeta.tsx +3 -1
- package/src/components/viewfinder/ImmersiveBottomBar.tsx +28 -17
- package/src/components/viewfinder/ImmersiveControls.tsx +26 -14
- package/src/components/viewfinder/ViewfinderControls.tsx +29 -7
- 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 +29 -3
- package/src/types/verification.ts +1 -1
- package/src/types/workflow.ts +15 -0
- package/src/screens/KeyPeopleInviteLinks.tsx +0 -239
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { radius, spacing } from '../config/theme';
|
|
5
|
+
import { resolveIdTypeDefinition } from '../config/idTypes';
|
|
6
|
+
import type { MultiIdPlan } from '../lib/multi-id';
|
|
7
|
+
import type { MultiIdSlot } from '../store/state';
|
|
8
|
+
import { useEffectiveCountry, useKyc, useTheme } from './runtime';
|
|
9
|
+
import { MyazaText } from './Typography';
|
|
10
|
+
import { Icon } from './Icon';
|
|
11
|
+
|
|
12
|
+
// The multi-ID run's position strip: one chip per check, filling in with the
|
|
13
|
+
// picked ID's name as each check commits, the current one highlighted.
|
|
14
|
+
//
|
|
15
|
+
// A PORT of the web SDK's MultiIdProgress. Rendered above the picker, the
|
|
16
|
+
// evidence steps and liveness so a reader always knows which of the run's IDs
|
|
17
|
+
// the screen is about and how many remain — without it a three-ID run is three
|
|
18
|
+
// visits to the same-looking screen with nothing saying which is which.
|
|
19
|
+
|
|
20
|
+
export function MultiIdProgress({ plan }: { plan: MultiIdPlan }): React.ReactElement {
|
|
21
|
+
const { colors } = useTheme();
|
|
22
|
+
const country = useEffectiveCountry();
|
|
23
|
+
const serverConfig = useKyc((s) => s.serverConfig);
|
|
24
|
+
const slots: MultiIdSlot[] = useKyc((s) => s.multiIdSlots);
|
|
25
|
+
|
|
26
|
+
const labelFor = (idType: string): string => {
|
|
27
|
+
const row = serverConfig.idTypes.find(
|
|
28
|
+
(r) => r.country === country && r.idType === idType,
|
|
29
|
+
);
|
|
30
|
+
return resolveIdTypeDefinition(country, idType, {
|
|
31
|
+
label: row?.label,
|
|
32
|
+
requiresDocumentCapture: row?.requiresDocumentCapture,
|
|
33
|
+
scanSides: row?.scanSides,
|
|
34
|
+
supportsNfc: row?.supportsNfc,
|
|
35
|
+
}).label;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<View
|
|
40
|
+
accessibilityLabel={`ID ${Math.min(plan.index + 1, plan.count)} of ${plan.count}`}
|
|
41
|
+
style={{
|
|
42
|
+
flexDirection: 'row',
|
|
43
|
+
alignItems: 'center',
|
|
44
|
+
gap: spacing.sm,
|
|
45
|
+
marginBottom: spacing.md,
|
|
46
|
+
paddingHorizontal: spacing.md,
|
|
47
|
+
paddingVertical: spacing.sm,
|
|
48
|
+
borderRadius: radius.md,
|
|
49
|
+
borderWidth: 1,
|
|
50
|
+
borderColor: colors.border,
|
|
51
|
+
backgroundColor: colors.backgroundSecondary,
|
|
52
|
+
}}
|
|
53
|
+
>
|
|
54
|
+
{Array.from({ length: plan.count }, (_, i) => {
|
|
55
|
+
const committed = slots[i];
|
|
56
|
+
const active = i === plan.index;
|
|
57
|
+
return (
|
|
58
|
+
<View
|
|
59
|
+
key={i}
|
|
60
|
+
style={{ flexDirection: 'row', alignItems: 'center', gap: spacing.sm, minWidth: 0 }}
|
|
61
|
+
>
|
|
62
|
+
{i > 0 && (
|
|
63
|
+
<View
|
|
64
|
+
style={{
|
|
65
|
+
height: 1,
|
|
66
|
+
width: 16,
|
|
67
|
+
backgroundColor: committed || active ? colors.primary : colors.border,
|
|
68
|
+
opacity: committed || active ? 0.5 : 1,
|
|
69
|
+
}}
|
|
70
|
+
/>
|
|
71
|
+
)}
|
|
72
|
+
<View
|
|
73
|
+
style={{
|
|
74
|
+
height: 20,
|
|
75
|
+
width: 20,
|
|
76
|
+
borderRadius: 10,
|
|
77
|
+
alignItems: 'center',
|
|
78
|
+
justifyContent: 'center',
|
|
79
|
+
backgroundColor: committed ? colors.primary : 'transparent',
|
|
80
|
+
borderWidth: committed ? 0 : active ? 2 : 1,
|
|
81
|
+
borderColor: active ? colors.primary : colors.border,
|
|
82
|
+
}}
|
|
83
|
+
>
|
|
84
|
+
{committed ? (
|
|
85
|
+
<Icon name="check" size={12} color={colors.onPrimary} />
|
|
86
|
+
) : (
|
|
87
|
+
<MyazaText
|
|
88
|
+
variant="bodySmall"
|
|
89
|
+
color={active ? colors.primary : colors.textMuted}
|
|
90
|
+
>
|
|
91
|
+
{String(i + 1)}
|
|
92
|
+
</MyazaText>
|
|
93
|
+
)}
|
|
94
|
+
</View>
|
|
95
|
+
<MyazaText
|
|
96
|
+
variant="bodySmall"
|
|
97
|
+
color={active ? colors.textDark : colors.textMuted}
|
|
98
|
+
numberOfLines={1}
|
|
99
|
+
>
|
|
100
|
+
{committed ? labelFor(committed.idType) : `ID ${i + 1}`}
|
|
101
|
+
</MyazaText>
|
|
102
|
+
</View>
|
|
103
|
+
);
|
|
104
|
+
})}
|
|
105
|
+
</View>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React, { useState } from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import { Pressable, ScrollView, View } from 'react-native';
|
|
3
3
|
|
|
4
4
|
import { radius, spacing } from '../config/theme';
|
|
5
5
|
import {
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import { Icon } from './Icon';
|
|
14
14
|
import { useTheme } from './runtime';
|
|
15
15
|
import { MyazaText } from './Typography';
|
|
16
|
+
import { FloatingSheet } from './glass/FloatingSheet';
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* A date field that opens a REAL calendar picker, replacing the "type
|
|
@@ -117,22 +118,8 @@ function DatePickerSheet({
|
|
|
117
118
|
!!initial && initial.year === cursor.year && initial.month0 === cursor.month0 && initial.day === d;
|
|
118
119
|
|
|
119
120
|
return (
|
|
120
|
-
<
|
|
121
|
-
|
|
122
|
-
onPress={onClose}
|
|
123
|
-
style={{ flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(0,0,0,0.5)' }}
|
|
124
|
-
>
|
|
125
|
-
{/* Stop the backdrop press from closing when the card itself is tapped. */}
|
|
126
|
-
<Pressable
|
|
127
|
-
onPress={() => undefined}
|
|
128
|
-
style={{
|
|
129
|
-
backgroundColor: colors.background,
|
|
130
|
-
borderTopLeftRadius: radius.xl,
|
|
131
|
-
borderTopRightRadius: radius.xl,
|
|
132
|
-
padding: spacing.md,
|
|
133
|
-
paddingBottom: spacing.xl,
|
|
134
|
-
}}
|
|
135
|
-
>
|
|
121
|
+
<FloatingSheet visible onClose={onClose} closeLabel="Close date picker">
|
|
122
|
+
<View style={{ padding: spacing.md, paddingTop: spacing.sm }}>
|
|
136
123
|
{/* Month header: step, or tap the title to jump years. */}
|
|
137
124
|
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: spacing.sm }}>
|
|
138
125
|
<Pressable
|
|
@@ -233,8 +220,7 @@ function DatePickerSheet({
|
|
|
233
220
|
))}
|
|
234
221
|
</>
|
|
235
222
|
)}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
</Modal>
|
|
223
|
+
</View>
|
|
224
|
+
</FloatingSheet>
|
|
239
225
|
);
|
|
240
226
|
}
|
|
@@ -39,6 +39,18 @@ export interface MyazaInputProps {
|
|
|
39
39
|
/** Rendered inside the field, after the text — a unit like "%". Same
|
|
40
40
|
* bordered-row treatment as `prefix`. */
|
|
41
41
|
suffix?: React.ReactNode;
|
|
42
|
+
/** Field height override — the results filter runs at 40 where every other
|
|
43
|
+
* input is 48, matching the web SDK's h-10 filter beside h-12 inputs. */
|
|
44
|
+
height?: number;
|
|
45
|
+
/** Text size override, for the compact filter (web: text-sm = 14). */
|
|
46
|
+
fontSize?: number;
|
|
47
|
+
/**
|
|
48
|
+
* Off for proper-noun fields (company names, registration numbers): iOS
|
|
49
|
+
* autocorrect rewrites them right before the person submits, so a search
|
|
50
|
+
* for the company they typed silently becomes a search for a word the
|
|
51
|
+
* keyboard preferred. Defaults on, like the platform.
|
|
52
|
+
*/
|
|
53
|
+
autoCorrect?: boolean;
|
|
42
54
|
onFocus?: FocusHandler;
|
|
43
55
|
onBlur?: BlurHandler;
|
|
44
56
|
}
|
|
@@ -57,6 +69,9 @@ export function MyazaInput({
|
|
|
57
69
|
autoFocus = false,
|
|
58
70
|
prefix,
|
|
59
71
|
suffix,
|
|
72
|
+
height,
|
|
73
|
+
fontSize,
|
|
74
|
+
autoCorrect = true,
|
|
60
75
|
onFocus,
|
|
61
76
|
onBlur,
|
|
62
77
|
}: MyazaInputProps): React.ReactElement {
|
|
@@ -95,7 +110,7 @@ export function MyazaInput({
|
|
|
95
110
|
style={{
|
|
96
111
|
flexDirection: 'row',
|
|
97
112
|
alignItems: 'center',
|
|
98
|
-
height: sizing.inputHeight,
|
|
113
|
+
height: height ?? sizing.inputHeight,
|
|
99
114
|
borderWidth,
|
|
100
115
|
borderColor,
|
|
101
116
|
borderRadius: radius.sm,
|
|
@@ -112,12 +127,14 @@ export function MyazaInput({
|
|
|
112
127
|
placeholderTextColor={colors.textMuted}
|
|
113
128
|
keyboardType={keyboardType}
|
|
114
129
|
autoCapitalize={autoCapitalize}
|
|
130
|
+
autoCorrect={autoCorrect}
|
|
131
|
+
spellCheck={autoCorrect}
|
|
115
132
|
maxLength={maxLength}
|
|
116
133
|
editable={editable}
|
|
117
134
|
autoFocus={autoFocus}
|
|
118
135
|
onFocus={handleFocus}
|
|
119
136
|
onBlur={handleBlur}
|
|
120
|
-
style={{ flex: 1, height: '100%', color: colors.textDark, fontSize: 16, fontFamily }}
|
|
137
|
+
style={{ flex: 1, height: '100%', color: colors.textDark, fontSize: fontSize ?? 16, fontFamily }}
|
|
121
138
|
/>
|
|
122
139
|
{suffix ? <View style={{ width: spacing.sm }} /> : null}
|
|
123
140
|
{suffix}
|
|
@@ -130,13 +147,15 @@ export function MyazaInput({
|
|
|
130
147
|
placeholderTextColor={colors.textMuted}
|
|
131
148
|
keyboardType={keyboardType}
|
|
132
149
|
autoCapitalize={autoCapitalize}
|
|
150
|
+
autoCorrect={autoCorrect}
|
|
151
|
+
spellCheck={autoCorrect}
|
|
133
152
|
maxLength={maxLength}
|
|
134
153
|
editable={editable}
|
|
135
154
|
autoFocus={autoFocus}
|
|
136
155
|
onFocus={handleFocus}
|
|
137
156
|
onBlur={handleBlur}
|
|
138
157
|
style={{
|
|
139
|
-
height: sizing.inputHeight,
|
|
158
|
+
height: height ?? sizing.inputHeight,
|
|
140
159
|
borderWidth,
|
|
141
160
|
borderColor,
|
|
142
161
|
borderRadius: radius.sm,
|
|
@@ -145,7 +164,7 @@ export function MyazaInput({
|
|
|
145
164
|
paddingHorizontal: spacing.md + (2 - borderWidth),
|
|
146
165
|
color: colors.textDark,
|
|
147
166
|
backgroundColor: colors.background,
|
|
148
|
-
fontSize: 16,
|
|
167
|
+
fontSize: fontSize ?? 16,
|
|
149
168
|
fontFamily,
|
|
150
169
|
}}
|
|
151
170
|
/>
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import React, { useState } from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import { Pressable, ScrollView, TextInput, useWindowDimensions, View } from 'react-native';
|
|
3
3
|
|
|
4
4
|
import { radius, spacing } from '../config/theme';
|
|
5
5
|
import { useTheme } from './runtime';
|
|
6
6
|
import { useInputFontFamily } from './fonts';
|
|
7
7
|
import { MyazaText } from './Typography';
|
|
8
8
|
import { Icon } from './Icon';
|
|
9
|
+
import { FloatingSheet } from './glass/FloatingSheet';
|
|
9
10
|
|
|
10
11
|
// ---------------------------------------------------------------------------
|
|
11
12
|
// The select field — a collapsed value that opens a sheet of options.
|
|
@@ -131,51 +132,23 @@ export function MyazaSelect<T extends string | number>({
|
|
|
131
132
|
<Icon name="chevron-down" size={18} color={colors.textSecondary} />
|
|
132
133
|
</Pressable>
|
|
133
134
|
|
|
134
|
-
<
|
|
135
|
+
<FloatingSheet
|
|
135
136
|
visible={open}
|
|
136
|
-
|
|
137
|
-
animationType="slide"
|
|
138
|
-
onRequestClose={() => {
|
|
137
|
+
onClose={() => {
|
|
139
138
|
setOpen(false);
|
|
140
139
|
setQuery('');
|
|
141
140
|
}}
|
|
141
|
+
// Long option lists stay inside the sheet and scroll.
|
|
142
|
+
maxHeight={windowHeight * 0.6}
|
|
143
|
+
closeLabel={`Close ${sheetTitle ?? hint}`}
|
|
142
144
|
>
|
|
143
|
-
{/* Tapping the backdrop dismisses, matching the platform sheet. */}
|
|
144
|
-
<Pressable
|
|
145
|
-
onPress={() => {
|
|
146
|
-
setOpen(false);
|
|
147
|
-
setQuery('');
|
|
148
|
-
}}
|
|
149
|
-
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.45)', justifyContent: 'flex-end' }}
|
|
150
|
-
>
|
|
151
|
-
{/* Stops a tap inside the sheet from closing it. */}
|
|
152
|
-
<Pressable
|
|
153
|
-
onPress={() => {}}
|
|
154
|
-
style={{
|
|
155
|
-
backgroundColor: colors.background,
|
|
156
|
-
borderTopLeftRadius: radius.lg,
|
|
157
|
-
borderTopRightRadius: radius.lg,
|
|
158
|
-
paddingBottom: spacing.lg,
|
|
159
|
-
// Long option lists stay inside the sheet and scroll.
|
|
160
|
-
maxHeight: windowHeight * 0.6,
|
|
161
|
-
}}
|
|
162
|
-
>
|
|
163
|
-
<View
|
|
164
|
-
style={{
|
|
165
|
-
alignSelf: 'center',
|
|
166
|
-
width: 36,
|
|
167
|
-
height: 4,
|
|
168
|
-
borderRadius: radius.full,
|
|
169
|
-
backgroundColor: colors.border,
|
|
170
|
-
marginTop: spacing.sm,
|
|
171
|
-
}}
|
|
172
|
-
/>
|
|
173
145
|
<MyazaText
|
|
174
146
|
variant="body"
|
|
175
147
|
style={{
|
|
176
148
|
fontWeight: '700',
|
|
177
149
|
paddingHorizontal: spacing.md,
|
|
178
|
-
|
|
150
|
+
// Tight under the handle, the way the system sheet titles sit.
|
|
151
|
+
paddingTop: spacing.xs,
|
|
179
152
|
paddingBottom: spacing.sm,
|
|
180
153
|
}}
|
|
181
154
|
>
|
|
@@ -222,7 +195,13 @@ export function MyazaSelect<T extends string | number>({
|
|
|
222
195
|
</View>
|
|
223
196
|
) : null}
|
|
224
197
|
|
|
225
|
-
<ScrollView
|
|
198
|
+
<ScrollView
|
|
199
|
+
bounces={false}
|
|
200
|
+
keyboardShouldPersistTaps="handled"
|
|
201
|
+
// The floating shell has no bottom padding of its own, so the
|
|
202
|
+
// last option must not sit flush against the rounded corner.
|
|
203
|
+
contentContainerStyle={{ paddingBottom: spacing.lg }}
|
|
204
|
+
>
|
|
226
205
|
{visible.length === 0 ? (
|
|
227
206
|
<MyazaText
|
|
228
207
|
variant="bodySmall"
|
|
@@ -280,9 +259,7 @@ export function MyazaSelect<T extends string | number>({
|
|
|
280
259
|
);
|
|
281
260
|
})}
|
|
282
261
|
</ScrollView>
|
|
283
|
-
|
|
284
|
-
</Pressable>
|
|
285
|
-
</Modal>
|
|
262
|
+
</FloatingSheet>
|
|
286
263
|
</>
|
|
287
264
|
);
|
|
288
265
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import React, { useRef, useState } from 'react';
|
|
2
|
+
import { PanResponder, View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { useTheme } from './runtime';
|
|
5
|
+
|
|
6
|
+
// ─── A 0–100 slider for the ownership field ──────────────────────────────────
|
|
7
|
+
//
|
|
8
|
+
// The fast coarse gesture beside the exact box: dragging writes whole numbers
|
|
9
|
+
// into the same field, and a register stake like 48.42 is still typed (no
|
|
10
|
+
// thumb lands on it). Dependency-free — a track, a fill and a thumb under one
|
|
11
|
+
// PanResponder — because a native slider module is a heavy import for one
|
|
12
|
+
// field. Mirrors the web SDK's range input under the same box.
|
|
13
|
+
|
|
14
|
+
const THUMB = 22;
|
|
15
|
+
const TRACK = 5;
|
|
16
|
+
|
|
17
|
+
export function OwnershipSlider({
|
|
18
|
+
value,
|
|
19
|
+
onChange,
|
|
20
|
+
}: {
|
|
21
|
+
/** The current stake 0–100; an undeclared one rests the thumb at 0. */
|
|
22
|
+
value: number;
|
|
23
|
+
onChange: (next: number) => void;
|
|
24
|
+
}): React.ReactElement {
|
|
25
|
+
const { colors } = useTheme();
|
|
26
|
+
const [width, setWidth] = useState(0);
|
|
27
|
+
const widthRef = useRef(0);
|
|
28
|
+
const onChangeRef = useRef(onChange);
|
|
29
|
+
onChangeRef.current = onChange;
|
|
30
|
+
|
|
31
|
+
const setFromX = (x: number): void => {
|
|
32
|
+
const usable = widthRef.current - THUMB;
|
|
33
|
+
if (usable <= 0) return;
|
|
34
|
+
const ratio = (x - THUMB / 2) / usable;
|
|
35
|
+
const next = Math.round(Math.min(1, Math.max(0, ratio)) * 100);
|
|
36
|
+
onChangeRef.current(next);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const pan = useRef(
|
|
40
|
+
PanResponder.create({
|
|
41
|
+
onStartShouldSetPanResponder: () => true,
|
|
42
|
+
onMoveShouldSetPanResponder: () => true,
|
|
43
|
+
onPanResponderGrant: (e) => setFromX(e.nativeEvent.locationX),
|
|
44
|
+
onPanResponderMove: (e) => setFromX(e.nativeEvent.locationX),
|
|
45
|
+
}),
|
|
46
|
+
).current;
|
|
47
|
+
|
|
48
|
+
const clamped = Math.min(100, Math.max(0, value));
|
|
49
|
+
const left = width > 0 ? (clamped / 100) * (width - THUMB) : 0;
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<View
|
|
53
|
+
{...pan.panHandlers}
|
|
54
|
+
onLayout={(e) => {
|
|
55
|
+
widthRef.current = e.nativeEvent.layout.width;
|
|
56
|
+
setWidth(e.nativeEvent.layout.width);
|
|
57
|
+
}}
|
|
58
|
+
accessibilityRole="adjustable"
|
|
59
|
+
accessibilityLabel="Ownership percentage"
|
|
60
|
+
accessibilityValue={{ min: 0, max: 100, now: clamped }}
|
|
61
|
+
// Tall hit area; the visible track stays thin.
|
|
62
|
+
style={{ height: 32, justifyContent: 'center' }}
|
|
63
|
+
>
|
|
64
|
+
<View
|
|
65
|
+
style={{
|
|
66
|
+
height: TRACK,
|
|
67
|
+
borderRadius: TRACK / 2,
|
|
68
|
+
backgroundColor: colors.border,
|
|
69
|
+
}}
|
|
70
|
+
/>
|
|
71
|
+
<View
|
|
72
|
+
pointerEvents="none"
|
|
73
|
+
style={{
|
|
74
|
+
position: 'absolute',
|
|
75
|
+
left: 0,
|
|
76
|
+
width: left + THUMB / 2,
|
|
77
|
+
height: TRACK,
|
|
78
|
+
borderRadius: TRACK / 2,
|
|
79
|
+
backgroundColor: colors.primary,
|
|
80
|
+
}}
|
|
81
|
+
/>
|
|
82
|
+
<View
|
|
83
|
+
pointerEvents="none"
|
|
84
|
+
style={{
|
|
85
|
+
position: 'absolute',
|
|
86
|
+
left,
|
|
87
|
+
width: THUMB,
|
|
88
|
+
height: THUMB,
|
|
89
|
+
borderRadius: THUMB / 2,
|
|
90
|
+
backgroundColor: colors.primary,
|
|
91
|
+
borderWidth: 3,
|
|
92
|
+
borderColor: colors.background,
|
|
93
|
+
shadowColor: '#000',
|
|
94
|
+
shadowOpacity: 0.15,
|
|
95
|
+
shadowRadius: 3,
|
|
96
|
+
shadowOffset: { width: 0, height: 1 },
|
|
97
|
+
elevation: 2,
|
|
98
|
+
}}
|
|
99
|
+
/>
|
|
100
|
+
</View>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useMemo, useState } from 'react';
|
|
1
|
+
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import { Pressable, View } from 'react-native';
|
|
3
3
|
|
|
4
4
|
import {
|
|
@@ -34,13 +34,25 @@ import { COUNTRY_NAMES } from '../config/countryNames.g';
|
|
|
34
34
|
// ---------------------------------------------------------------------------
|
|
35
35
|
|
|
36
36
|
export function PhoneNumberInput({
|
|
37
|
+
value,
|
|
37
38
|
defaultCountry,
|
|
38
39
|
disabled,
|
|
40
|
+
autoFocus = false,
|
|
39
41
|
onChange,
|
|
40
42
|
}: {
|
|
43
|
+
/**
|
|
44
|
+
* E.164 seed. The field itself stays uncontrolled (the formatter owns the
|
|
45
|
+
* text), but a value arriving after mount — the register's, prefilled a
|
|
46
|
+
* moment later — has to reach the field, or we hold a number the applicant
|
|
47
|
+
* can neither see nor correct and submit it as though they gave it.
|
|
48
|
+
*/
|
|
49
|
+
value?: string;
|
|
41
50
|
/** Seeds the picker (ISO-2). Falls back to NG. */
|
|
42
51
|
defaultCountry?: string;
|
|
43
52
|
disabled?: boolean;
|
|
53
|
+
/** Focus + keyboard on mount. Opt-in: right for a single-field OTP screen,
|
|
54
|
+
* wrong for a row in a company-profile form. */
|
|
55
|
+
autoFocus?: boolean;
|
|
44
56
|
/** Fires on every edit with the E.164 value ('' until parseable) + validity. */
|
|
45
57
|
onChange: (value: { e164: string; isValid: boolean; country: string }) => void;
|
|
46
58
|
}): React.ReactElement {
|
|
@@ -64,11 +76,32 @@ export function PhoneNumberInput({
|
|
|
64
76
|
[],
|
|
65
77
|
);
|
|
66
78
|
|
|
67
|
-
|
|
79
|
+
// A supplied number decides its OWN dial code — it is a fact, where the
|
|
80
|
+
// country prop is a guess. Falls through to the guess when there is no
|
|
81
|
+
// number or it cannot be parsed.
|
|
82
|
+
const supplied = value ? parsePhoneNumberFromString(value) : null;
|
|
83
|
+
const seed = (supplied?.country ??
|
|
84
|
+
defaultCountry?.toUpperCase() ??
|
|
85
|
+
'NG') as CountryCode;
|
|
68
86
|
const [country, setCountry] = useState<CountryCode>(
|
|
69
87
|
options.some((o) => o.code === seed) ? seed : ('NG' as CountryCode),
|
|
70
88
|
);
|
|
71
|
-
const [national, setNational] = useState(
|
|
89
|
+
const [national, setNational] = useState(() =>
|
|
90
|
+
supplied ? formatNationalNumber(supplied.nationalNumber, seed) : '',
|
|
91
|
+
);
|
|
92
|
+
// A number that arrives AFTER mount — the register's, prefilled a moment
|
|
93
|
+
// later — has to reach the field. Seeding is one-way and keyed on the value,
|
|
94
|
+
// so the applicant's own edits are never fought.
|
|
95
|
+
const seededRef = useRef(value ?? '');
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (!value || value === seededRef.current) return;
|
|
98
|
+
seededRef.current = value;
|
|
99
|
+
const parsed = parsePhoneNumberFromString(value);
|
|
100
|
+
if (!parsed) return;
|
|
101
|
+
const next = (parsed.country ?? country) as CountryCode;
|
|
102
|
+
setCountry(next);
|
|
103
|
+
setNational(formatNationalNumber(parsed.nationalNumber, next));
|
|
104
|
+
}, [value, country]);
|
|
72
105
|
const [open, setOpen] = useState(false);
|
|
73
106
|
|
|
74
107
|
const selected = options.find((o) => o.code === country);
|
|
@@ -132,7 +165,7 @@ export function PhoneNumberInput({
|
|
|
132
165
|
placeholder="803 123 4567"
|
|
133
166
|
keyboardType="phone-pad"
|
|
134
167
|
editable={!disabled}
|
|
135
|
-
autoFocus
|
|
168
|
+
autoFocus={autoFocus}
|
|
136
169
|
/>
|
|
137
170
|
</View>
|
|
138
171
|
</View>
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { spacing } from '../config/theme';
|
|
5
|
+
import type { KycState } from '../store/state';
|
|
6
|
+
import { useKyc } from './runtime';
|
|
7
|
+
import { Icon } from './Icon';
|
|
8
|
+
import { MyazaText } from './Typography';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* "You are not in production" strip, shown above the sheet's header.
|
|
12
|
+
*
|
|
13
|
+
* A sandbox flow is pixel-identical to a live one, which is the point — you are
|
|
14
|
+
* testing the real thing — and also the hazard: screenshots get mistaken for
|
|
15
|
+
* production incidents, testers wonder why a real passport was rejected, and a
|
|
16
|
+
* `pk_test_` key shipped to production looks like it works right up until
|
|
17
|
+
* nobody is actually verified.
|
|
18
|
+
*
|
|
19
|
+
* Environment comes from the SERVER (`/config`), not the API key prefix. Hosted
|
|
20
|
+
* sessions authenticate with an `hs_` handoff token that carries no environment
|
|
21
|
+
* slot, so key-sniffing would leave exactly the surface an end user sees
|
|
22
|
+
* unlabelled.
|
|
23
|
+
*
|
|
24
|
+
* DEVELOPMENT is labelled too, and differently: it runs the real pipeline
|
|
25
|
+
* against staging provider credentials, so "test data only" would be a lie
|
|
26
|
+
* there. 1:1 with the web and Flutter SDKs.
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* Whether the banner will render. The sheet needs this to decide who owns the
|
|
30
|
+
* safe-area top inset: the banner sits ABOVE the header, so when it shows it is
|
|
31
|
+
* the topmost element and must clear the status bar itself — otherwise the
|
|
32
|
+
* header pads for an inset the banner already crossed, and the strip is drawn
|
|
33
|
+
* under the clock on Android's edge-to-edge modals.
|
|
34
|
+
*/
|
|
35
|
+
export function useSandboxBannerVisible(): boolean {
|
|
36
|
+
const environment = useKyc((s: KycState) => s.serverConfig.environment);
|
|
37
|
+
return environment === 'SANDBOX' || environment === 'DEVELOPMENT';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function SandboxBanner({
|
|
41
|
+
topInset = 0,
|
|
42
|
+
}: {
|
|
43
|
+
/** Safe-area top inset to absorb, when the banner is the topmost element. */
|
|
44
|
+
topInset?: number;
|
|
45
|
+
}): React.ReactElement | null {
|
|
46
|
+
const environment = useKyc((s: KycState) => s.serverConfig.environment);
|
|
47
|
+
if (environment !== 'SANDBOX' && environment !== 'DEVELOPMENT') return null;
|
|
48
|
+
|
|
49
|
+
const sandbox = environment === 'SANDBOX';
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<View
|
|
53
|
+
accessibilityRole="alert"
|
|
54
|
+
style={{
|
|
55
|
+
flexDirection: 'row',
|
|
56
|
+
alignItems: 'center',
|
|
57
|
+
justifyContent: 'center',
|
|
58
|
+
gap: spacing.xs + 2,
|
|
59
|
+
// Amber, not the brand colour: this is an out-of-band status about the
|
|
60
|
+
// session, and the org's palette would read as part of their flow.
|
|
61
|
+
backgroundColor: AMBER_TINT,
|
|
62
|
+
paddingHorizontal: spacing.md,
|
|
63
|
+
// The tint extends up through the status bar so the strip reads as one
|
|
64
|
+
// band rather than floating below a bare gap.
|
|
65
|
+
paddingTop: topInset + spacing.xs + 2,
|
|
66
|
+
paddingBottom: spacing.xs + 2,
|
|
67
|
+
}}
|
|
68
|
+
>
|
|
69
|
+
{/* Flask, not an alert mark: nothing is wrong — this is a statement about
|
|
70
|
+
WHICH environment you are in ("test environment", "test data only").
|
|
71
|
+
An alert glyph reads as a fault and sends testers hunting for one.
|
|
72
|
+
Same lucide icon the web SDK uses; Flutter mirrors it with
|
|
73
|
+
Icons.science_outlined. */}
|
|
74
|
+
<Icon name="flask" size={13} color={AMBER_INK} />
|
|
75
|
+
<MyazaText
|
|
76
|
+
variant="bodySmall"
|
|
77
|
+
color={AMBER_INK}
|
|
78
|
+
style={{ fontSize: 11, fontWeight: '700', letterSpacing: 1, textTransform: 'uppercase' }}
|
|
79
|
+
>
|
|
80
|
+
{sandbox ? 'Sandbox' : 'Development'}
|
|
81
|
+
</MyazaText>
|
|
82
|
+
<MyazaText variant="bodySmall" color={AMBER_INK} style={{ fontSize: 11, flexShrink: 1 }}>
|
|
83
|
+
{sandbox ? 'Test data only, no real checks run' : 'Test environment, results are not live'}
|
|
84
|
+
</MyazaText>
|
|
85
|
+
</View>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Fixed rather than themed: the strip must read identically in light and dark,
|
|
90
|
+
// and it deliberately does not belong to the org's palette.
|
|
91
|
+
const AMBER_TINT = 'rgba(245, 158, 11, 0.18)';
|
|
92
|
+
const AMBER_INK = '#B45309';
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { AccessibilityInfo, Animated } from 'react-native';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Whether the OS asks for reduced motion. Shared by the entrance/skeleton
|
|
6
|
+
* animations so a system-level preference silences all of them at once.
|
|
7
|
+
*/
|
|
8
|
+
export function useReduceMotion(): boolean {
|
|
9
|
+
const [reduced, setReduced] = useState(false);
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
let cancelled = false;
|
|
12
|
+
void AccessibilityInfo.isReduceMotionEnabled().then((r) => {
|
|
13
|
+
if (!cancelled) setReduced(r);
|
|
14
|
+
});
|
|
15
|
+
const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduced);
|
|
16
|
+
return () => {
|
|
17
|
+
cancelled = true;
|
|
18
|
+
sub.remove();
|
|
19
|
+
};
|
|
20
|
+
}, []);
|
|
21
|
+
return reduced;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Rise-and-fade entrance for list items, mirroring the web SDK's staggered
|
|
26
|
+
* `animate-slide-up`: each card enters in reading order, ~45ms apart, so a
|
|
27
|
+
* resolved list reads as the skeleton before it settling into place rather
|
|
28
|
+
* than a screen swap. Reduced motion renders children statically.
|
|
29
|
+
*/
|
|
30
|
+
export function StaggerIn({
|
|
31
|
+
delayMs = 0,
|
|
32
|
+
children,
|
|
33
|
+
}: {
|
|
34
|
+
delayMs?: number;
|
|
35
|
+
children: React.ReactNode;
|
|
36
|
+
}): React.ReactElement {
|
|
37
|
+
const reduceMotion = useReduceMotion();
|
|
38
|
+
const progress = useRef(new Animated.Value(0)).current;
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
Animated.timing(progress, {
|
|
42
|
+
toValue: 1,
|
|
43
|
+
duration: 300,
|
|
44
|
+
delay: delayMs,
|
|
45
|
+
useNativeDriver: true,
|
|
46
|
+
}).start();
|
|
47
|
+
}, [progress, delayMs]);
|
|
48
|
+
|
|
49
|
+
if (reduceMotion) return <>{children}</>;
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<Animated.View
|
|
53
|
+
style={{
|
|
54
|
+
opacity: progress,
|
|
55
|
+
transform: [
|
|
56
|
+
{ translateY: progress.interpolate({ inputRange: [0, 1], outputRange: [16, 0] }) },
|
|
57
|
+
],
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
60
|
+
{children}
|
|
61
|
+
</Animated.View>
|
|
62
|
+
);
|
|
63
|
+
}
|