@myazahq/kyc-sdk-react-native 2.1.0 → 2.3.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/README.md +43 -2
- package/package.json +1 -1
- package/src/components/Icon.tsx +3 -0
- package/src/components/KycSheet.tsx +209 -127
- package/src/components/MyazaInput.tsx +6 -1
- package/src/components/MyazaSelect.tsx +5 -0
- package/src/components/PoweredBy.tsx +20 -15
- package/src/components/ProgressBar.tsx +91 -0
- package/src/components/StepIndicator.tsx +145 -31
- package/src/components/fonts.ts +23 -0
- package/src/config/keyPeople.ts +10 -0
- package/src/config/questionnaire.ts +45 -4
- package/src/config/workflowMerge.ts +1 -0
- package/src/emrtd/crypto.ts +1 -1
- package/src/emrtd/dg1.ts +55 -0
- package/src/emrtd/ec-curves.ts +142 -0
- package/src/emrtd/ec.ts +196 -0
- package/src/emrtd/index.ts +1 -0
- package/src/emrtd/mrzKey.ts +21 -1
- package/src/emrtd/open.ts +169 -0
- package/src/emrtd/pace-params.ts +169 -0
- package/src/emrtd/pace.ts +295 -0
- package/src/emrtd/secureMessaging.ts +32 -14
- package/src/emrtd/session.ts +124 -20
- package/src/emrtd/suites.ts +99 -0
- package/src/index.ts +1 -0
- package/src/lib/step-log.ts +43 -0
- package/src/lib/step-window.ts +96 -0
- package/src/liveness/useLiveness.ts +1 -1
- package/src/screens/ApplicantRoleStep.tsx +1 -10
- package/src/screens/BusinessKeyPeopleStep.tsx +101 -45
- package/src/screens/IdTypeStep.tsx +15 -2
- package/src/screens/KeyPersonCard.tsx +119 -0
- package/src/screens/{BusinessKeyPersonRow.tsx → KeyPersonForm.tsx} +35 -57
- package/src/screens/KeyPersonSheet.tsx +184 -0
- package/src/screens/NfcStep.tsx +12 -0
- package/src/screens/QuestionnaireField.tsx +30 -0
- package/src/screens/QuestionnaireStep.tsx +3 -1
- package/src/screens/nfc/NfcSuccessPanel.tsx +13 -6
- package/src/services/deviceMetadata.ts +9 -1
- package/src/store/derive.ts +7 -0
- package/src/store/kycStore.ts +25 -1
- package/src/types/config.ts +17 -0
- package/src/types/workflow.ts +10 -0
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import React, { useMemo } from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import { View } from 'react-native';
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
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
8
|
import { MyazaSelect } from '../components/MyazaSelect';
|
|
9
9
|
import { CountryField } from '../components/CountryField';
|
|
10
10
|
import type { DialCodeOption } from '../components/DialCodePicker';
|
|
11
|
-
import { Icon } from '../components/Icon';
|
|
12
11
|
import { ALL_REGION_CODES, regionCountryName } from '../config/regions';
|
|
13
12
|
import { isValidContactEmail } from '../config/contact';
|
|
14
13
|
import {
|
|
@@ -19,20 +18,18 @@ import {
|
|
|
19
18
|
import type { KeyPersonRole } from '../types/business';
|
|
20
19
|
|
|
21
20
|
// ---------------------------------------------------------------------------
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
// Validation is LIVE per field, like the web: a broken value says so as it is
|
|
27
|
-
// typed, while blank optional fields stay quiet.
|
|
21
|
+
// The key-person FIELDS — full name → role → ownership % → country → email —
|
|
22
|
+
// with the same live per-field validation the old inline row had. No card
|
|
23
|
+
// chrome, no header: this is the body of the add/edit sheet (KeyPersonSheet),
|
|
24
|
+
// which owns the draft state and the save/remove actions.
|
|
28
25
|
// ---------------------------------------------------------------------------
|
|
29
26
|
|
|
30
|
-
/** Whether the
|
|
27
|
+
/** Whether the role is an ownership one (% is only meaningful then). */
|
|
31
28
|
function isOwnerRole(role: KeyPersonRole): boolean {
|
|
32
29
|
return role === 'beneficial_owner' || role === 'shareholder';
|
|
33
30
|
}
|
|
34
31
|
|
|
35
|
-
/** Every ISO-2 we can name, alphabetical — built once, shared by all
|
|
32
|
+
/** Every ISO-2 we can name, alphabetical — built once, shared by all mounts. */
|
|
36
33
|
let countryOptions: DialCodeOption[] | null = null;
|
|
37
34
|
function allCountryOptions(): DialCodeOption[] {
|
|
38
35
|
countryOptions ??= [...ALL_REGION_CODES]
|
|
@@ -41,66 +38,44 @@ function allCountryOptions(): DialCodeOption[] {
|
|
|
41
38
|
return countryOptions;
|
|
42
39
|
}
|
|
43
40
|
|
|
44
|
-
export function
|
|
45
|
-
|
|
46
|
-
index,
|
|
41
|
+
export function KeyPersonForm({
|
|
42
|
+
entry,
|
|
47
43
|
onChange,
|
|
48
|
-
onRemove,
|
|
49
44
|
uboThreshold = 25,
|
|
45
|
+
combinedPctError = null,
|
|
50
46
|
}: {
|
|
51
|
-
|
|
52
|
-
index: number;
|
|
47
|
+
entry: KeyPersonEntry;
|
|
53
48
|
onChange: (patch: Partial<KeyPersonEntry>) => void;
|
|
54
|
-
onRemove: () => void;
|
|
55
49
|
/** Ownership % at/above which the server treats a person as a beneficial
|
|
56
50
|
* owner (the workflow's `keyPeople.ownershipThreshold`, default 25). */
|
|
57
51
|
uboThreshold?: number;
|
|
52
|
+
/**
|
|
53
|
+
* Set when this draft's % would push the COMBINED ownership across all
|
|
54
|
+
* people past 100% — shown on the % field as a warning. It never blocks
|
|
55
|
+
* saving (the fix may live on a different person); the list's summary and
|
|
56
|
+
* the disabled Continue enforce the total.
|
|
57
|
+
*/
|
|
58
|
+
combinedPctError?: string | null;
|
|
58
59
|
}): React.ReactElement {
|
|
59
60
|
const { colors } = useTheme();
|
|
60
61
|
const options = useMemo(allCountryOptions, []);
|
|
61
62
|
|
|
62
|
-
const nameInvalid =
|
|
63
|
-
const emailInvalid =
|
|
64
|
-
const pct =
|
|
63
|
+
const nameInvalid = entry.name !== '' && entry.name.trim().length < 2;
|
|
64
|
+
const emailInvalid = entry.email.trim() !== '' && !isValidContactEmail(entry.email.trim());
|
|
65
|
+
const pct = entry.ownershipPct.trim();
|
|
65
66
|
const pctNum = Number(pct);
|
|
66
67
|
const pctInvalid = pct !== '' && (!Number.isFinite(pctNum) || pctNum < 0 || pctNum > 100);
|
|
67
68
|
// Surface the regulatory consequence as feedback: at/above the threshold the
|
|
68
69
|
// server escalates this person to a beneficial owner regardless of the role
|
|
69
70
|
// picked. Quiet when they already chose UBO — nothing new to say.
|
|
70
71
|
const uboHint =
|
|
71
|
-
!pctInvalid && pct !== '' && pctNum >= uboThreshold &&
|
|
72
|
+
!pctInvalid && pct !== '' && pctNum >= uboThreshold && entry.role !== 'beneficial_owner';
|
|
72
73
|
|
|
73
74
|
return (
|
|
74
|
-
<View
|
|
75
|
-
style={{
|
|
76
|
-
borderWidth: 1,
|
|
77
|
-
borderColor: colors.border,
|
|
78
|
-
borderRadius: radius.sm,
|
|
79
|
-
padding: spacing.md,
|
|
80
|
-
marginTop: spacing.sm + 4,
|
|
81
|
-
}}
|
|
82
|
-
>
|
|
83
|
-
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: spacing.sm }}>
|
|
84
|
-
<MyazaText
|
|
85
|
-
variant="bodySmall"
|
|
86
|
-
color={colors.textSecondary}
|
|
87
|
-
style={{ flex: 1, fontWeight: '600', letterSpacing: 0.6 }}
|
|
88
|
-
>
|
|
89
|
-
{`PERSON ${index + 1}`}
|
|
90
|
-
</MyazaText>
|
|
91
|
-
<Pressable
|
|
92
|
-
onPress={onRemove}
|
|
93
|
-
accessibilityRole="button"
|
|
94
|
-
accessibilityLabel={`Remove person ${index + 1}`}
|
|
95
|
-
hitSlop={8}
|
|
96
|
-
>
|
|
97
|
-
<Icon name="close" size={16} color={colors.textMuted} />
|
|
98
|
-
</Pressable>
|
|
99
|
-
</View>
|
|
100
|
-
|
|
75
|
+
<View>
|
|
101
76
|
<MyazaInput
|
|
102
77
|
label="Full name"
|
|
103
|
-
value={
|
|
78
|
+
value={entry.name}
|
|
104
79
|
onChangeText={(name) => onChange({ name })}
|
|
105
80
|
placeholder="e.g. Bola Owner"
|
|
106
81
|
// It's a person's name — start every word capitalized.
|
|
@@ -113,7 +88,7 @@ export function BusinessKeyPersonRow({
|
|
|
113
88
|
Role
|
|
114
89
|
</MyazaText>
|
|
115
90
|
<MyazaSelect
|
|
116
|
-
value={
|
|
91
|
+
value={entry.role}
|
|
117
92
|
sheetTitle="Role"
|
|
118
93
|
options={KEY_PERSON_ROLES.map((role) => ({
|
|
119
94
|
value: role,
|
|
@@ -125,16 +100,16 @@ export function BusinessKeyPersonRow({
|
|
|
125
100
|
<View style={{ height: spacing.sm }} />
|
|
126
101
|
<MyazaInput
|
|
127
102
|
label="Ownership % (optional)"
|
|
128
|
-
value={
|
|
103
|
+
value={entry.ownershipPct}
|
|
129
104
|
onChangeText={(ownershipPct) => onChange({ ownershipPct })}
|
|
130
|
-
placeholder={isOwnerRole(
|
|
105
|
+
placeholder={isOwnerRole(entry.role) ? 'e.g. 60' : '—'}
|
|
131
106
|
keyboardType="decimal-pad"
|
|
132
107
|
suffix={
|
|
133
108
|
<MyazaText variant="bodySmall" color={colors.textMuted}>
|
|
134
109
|
%
|
|
135
110
|
</MyazaText>
|
|
136
111
|
}
|
|
137
|
-
error={pctInvalid ? 'Enter a value between 0 and 100.' :
|
|
112
|
+
error={pctInvalid ? 'Enter a value between 0 and 100.' : combinedPctError}
|
|
138
113
|
helper={
|
|
139
114
|
uboHint
|
|
140
115
|
? `At ${uboThreshold}% or more, this person counts as a beneficial owner.`
|
|
@@ -144,13 +119,16 @@ export function BusinessKeyPersonRow({
|
|
|
144
119
|
|
|
145
120
|
<View style={{ height: spacing.sm }} />
|
|
146
121
|
<MyazaText variant="bodySmall" style={{ fontWeight: '600', marginBottom: spacing.xs }}>
|
|
147
|
-
Country
|
|
122
|
+
Country{' '}
|
|
123
|
+
<MyazaText variant="bodySmall" color={colors.textSecondary}>
|
|
124
|
+
(where their ID was issued)
|
|
125
|
+
</MyazaText>
|
|
148
126
|
</MyazaText>
|
|
149
127
|
{/* The SAME sheet as the phone field's dial-code picker (keyboard-aware,
|
|
150
128
|
autofocused search, results pinned above the keys) — minus the dial
|
|
151
129
|
codes. Two country pickers that feel different would read as a bug. */}
|
|
152
130
|
<CountryField
|
|
153
|
-
value={
|
|
131
|
+
value={entry.country || null}
|
|
154
132
|
options={options}
|
|
155
133
|
onChange={(country) => onChange({ country })}
|
|
156
134
|
/>
|
|
@@ -158,7 +136,7 @@ export function BusinessKeyPersonRow({
|
|
|
158
136
|
<View style={{ height: spacing.sm }} />
|
|
159
137
|
<MyazaInput
|
|
160
138
|
label="Email (optional — used to send their verification link)"
|
|
161
|
-
value={
|
|
139
|
+
value={entry.email}
|
|
162
140
|
onChangeText={(email) => onChange({ email })}
|
|
163
141
|
placeholder="name@company.com"
|
|
164
142
|
keyboardType="email-address"
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Keyboard,
|
|
4
|
+
Modal,
|
|
5
|
+
Platform,
|
|
6
|
+
Pressable,
|
|
7
|
+
ScrollView,
|
|
8
|
+
useWindowDimensions,
|
|
9
|
+
View,
|
|
10
|
+
} from 'react-native';
|
|
11
|
+
|
|
12
|
+
import { radius, spacing } from '../config/theme';
|
|
13
|
+
import { useTheme } from '../components/runtime';
|
|
14
|
+
import { MyazaText } from '../components/Typography';
|
|
15
|
+
import { MyazaButton } from '../components/MyazaButton';
|
|
16
|
+
import { Icon } from '../components/Icon';
|
|
17
|
+
import { KeyPersonForm } from './KeyPersonForm';
|
|
18
|
+
import { isKeyPersonRowValid, type KeyPersonEntry } from '../config/keyPeople';
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// The add/edit key-person sheet. The list step stays a clean stack of summary
|
|
22
|
+
// cards; the FORM lives here — slide-up sheet, grab handle, the five fields,
|
|
23
|
+
// and a pinned primary action. Editing adds a visually separated destructive
|
|
24
|
+
// "Remove this person" beneath the save (never beside it — HIG destructive
|
|
25
|
+
// separation).
|
|
26
|
+
//
|
|
27
|
+
// Keyboard handling mirrors DialCodePicker: the sheet is lifted above the keys
|
|
28
|
+
// and sized against the space that remains, so the focused field is never
|
|
29
|
+
// underneath the keyboard.
|
|
30
|
+
//
|
|
31
|
+
// Save is enabled once the draft is valid. A combined-ownership overshoot
|
|
32
|
+
// WARNS here but never blocks saving — the fix may live on a different
|
|
33
|
+
// person's %, and trapping the user inside this sheet would force them to
|
|
34
|
+
// discard their work to go adjust it.
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
export function KeyPersonSheet({
|
|
38
|
+
mode,
|
|
39
|
+
initial,
|
|
40
|
+
uboThreshold,
|
|
41
|
+
otherPctTotal,
|
|
42
|
+
onSave,
|
|
43
|
+
onRemove,
|
|
44
|
+
onClose,
|
|
45
|
+
}: {
|
|
46
|
+
mode: 'add' | 'edit';
|
|
47
|
+
initial: KeyPersonEntry;
|
|
48
|
+
uboThreshold?: number;
|
|
49
|
+
/** Sum of every OTHER person's ownership % — for the combined warning. */
|
|
50
|
+
otherPctTotal: number;
|
|
51
|
+
onSave: (entry: KeyPersonEntry) => void;
|
|
52
|
+
/** Edit mode only — removes the person and closes. */
|
|
53
|
+
onRemove?: () => void;
|
|
54
|
+
onClose: () => void;
|
|
55
|
+
}): React.ReactElement {
|
|
56
|
+
const { colors } = useTheme();
|
|
57
|
+
const { height: screenHeight } = useWindowDimensions();
|
|
58
|
+
const [draft, setDraft] = useState<KeyPersonEntry>(initial);
|
|
59
|
+
const [keyboard, setKeyboard] = useState(0);
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
// iOS reports 'will' events early enough to resize before the keys land;
|
|
63
|
+
// Android only emits 'did'.
|
|
64
|
+
const show = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
|
65
|
+
const hide = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
|
66
|
+
const shown = Keyboard.addListener(show, (e) => setKeyboard(e.endCoordinates.height));
|
|
67
|
+
const hidden = Keyboard.addListener(hide, () => setKeyboard(0));
|
|
68
|
+
return () => {
|
|
69
|
+
shown.remove();
|
|
70
|
+
hidden.remove();
|
|
71
|
+
};
|
|
72
|
+
}, []);
|
|
73
|
+
|
|
74
|
+
const available = screenHeight - keyboard;
|
|
75
|
+
const maxHeight = Math.min(available * 0.92, screenHeight * 0.85);
|
|
76
|
+
|
|
77
|
+
const draftPct = Number(draft.ownershipPct);
|
|
78
|
+
const combinedTotal =
|
|
79
|
+
draft.ownershipPct.trim() !== '' && Number.isFinite(draftPct)
|
|
80
|
+
? otherPctTotal + draftPct
|
|
81
|
+
: otherPctTotal;
|
|
82
|
+
const fmtPct = (n: number): string => (Number.isInteger(n) ? String(n) : n.toFixed(1));
|
|
83
|
+
const combinedPctError =
|
|
84
|
+
combinedTotal > 100
|
|
85
|
+
? `Combined ownership would be ${fmtPct(combinedTotal)}% — over by ${fmtPct(combinedTotal - 100)}%.`
|
|
86
|
+
: null;
|
|
87
|
+
|
|
88
|
+
const canSave = isKeyPersonRowValid(draft) && draft.name.trim().length >= 2;
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<Modal visible transparent animationType="slide" onRequestClose={onClose}>
|
|
92
|
+
{/* Same wrapper as MyazaSelect's option sheet: a flex-end backdrop with
|
|
93
|
+
the sheet inside it, so the top corners round identically. Tapping
|
|
94
|
+
the backdrop dismisses; a tap inside the sheet does not. */}
|
|
95
|
+
<Pressable
|
|
96
|
+
onPress={onClose}
|
|
97
|
+
accessibilityLabel="Close"
|
|
98
|
+
style={{
|
|
99
|
+
flex: 1,
|
|
100
|
+
backgroundColor: 'rgba(0,0,0,0.45)',
|
|
101
|
+
justifyContent: 'flex-end',
|
|
102
|
+
}}
|
|
103
|
+
>
|
|
104
|
+
<Pressable
|
|
105
|
+
onPress={() => {}}
|
|
106
|
+
style={{
|
|
107
|
+
maxHeight,
|
|
108
|
+
marginBottom: keyboard,
|
|
109
|
+
backgroundColor: colors.background,
|
|
110
|
+
borderTopLeftRadius: radius.lg,
|
|
111
|
+
borderTopRightRadius: radius.lg,
|
|
112
|
+
overflow: 'hidden',
|
|
113
|
+
}}
|
|
114
|
+
>
|
|
115
|
+
<View
|
|
116
|
+
style={{
|
|
117
|
+
alignSelf: 'center',
|
|
118
|
+
width: 36,
|
|
119
|
+
height: 4,
|
|
120
|
+
borderRadius: radius.full,
|
|
121
|
+
backgroundColor: colors.border,
|
|
122
|
+
marginTop: spacing.sm,
|
|
123
|
+
}}
|
|
124
|
+
/>
|
|
125
|
+
<View
|
|
126
|
+
style={{
|
|
127
|
+
flexDirection: 'row',
|
|
128
|
+
alignItems: 'center',
|
|
129
|
+
paddingHorizontal: spacing.md,
|
|
130
|
+
paddingTop: spacing.sm + 4,
|
|
131
|
+
paddingBottom: spacing.sm,
|
|
132
|
+
}}
|
|
133
|
+
>
|
|
134
|
+
<MyazaText variant="body" style={{ flex: 1, fontWeight: '700' }}>
|
|
135
|
+
{mode === 'add' ? 'Add a person' : 'Edit person'}
|
|
136
|
+
</MyazaText>
|
|
137
|
+
<Pressable onPress={onClose} hitSlop={12} accessibilityRole="button" accessibilityLabel="Close">
|
|
138
|
+
<Icon name="x" size={20} color={colors.textSecondary} />
|
|
139
|
+
</Pressable>
|
|
140
|
+
</View>
|
|
141
|
+
|
|
142
|
+
<ScrollView
|
|
143
|
+
bounces={false}
|
|
144
|
+
keyboardShouldPersistTaps="handled"
|
|
145
|
+
contentContainerStyle={{ paddingHorizontal: spacing.md, paddingBottom: spacing.sm }}
|
|
146
|
+
>
|
|
147
|
+
<KeyPersonForm
|
|
148
|
+
entry={draft}
|
|
149
|
+
onChange={(patch) => setDraft((d) => ({ ...d, ...patch }))}
|
|
150
|
+
uboThreshold={uboThreshold}
|
|
151
|
+
combinedPctError={combinedPctError}
|
|
152
|
+
/>
|
|
153
|
+
</ScrollView>
|
|
154
|
+
|
|
155
|
+
<View style={{ paddingHorizontal: spacing.md, paddingTop: spacing.sm, paddingBottom: spacing.lg }}>
|
|
156
|
+
<MyazaButton
|
|
157
|
+
label={mode === 'add' ? 'Add person' : 'Save changes'}
|
|
158
|
+
onPress={canSave ? () => onSave(draft) : undefined}
|
|
159
|
+
disabled={!canSave}
|
|
160
|
+
/>
|
|
161
|
+
{mode === 'edit' && onRemove ? (
|
|
162
|
+
<Pressable
|
|
163
|
+
onPress={onRemove}
|
|
164
|
+
accessibilityRole="button"
|
|
165
|
+
accessibilityLabel="Remove this person"
|
|
166
|
+
style={({ pressed }) => ({
|
|
167
|
+
height: 44,
|
|
168
|
+
alignItems: 'center',
|
|
169
|
+
justifyContent: 'center',
|
|
170
|
+
marginTop: spacing.xs,
|
|
171
|
+
opacity: pressed ? 0.7 : 1,
|
|
172
|
+
})}
|
|
173
|
+
>
|
|
174
|
+
<MyazaText variant="bodySmall" color={colors.error} style={{ fontWeight: '600' }}>
|
|
175
|
+
Remove this person
|
|
176
|
+
</MyazaText>
|
|
177
|
+
</Pressable>
|
|
178
|
+
) : null}
|
|
179
|
+
</View>
|
|
180
|
+
</Pressable>
|
|
181
|
+
</Pressable>
|
|
182
|
+
</Modal>
|
|
183
|
+
);
|
|
184
|
+
}
|
package/src/screens/NfcStep.tsx
CHANGED
|
@@ -151,6 +151,18 @@ export function NfcStep(): React.ReactElement {
|
|
|
151
151
|
},
|
|
152
152
|
);
|
|
153
153
|
if (!liveRef.current || seq !== readSeqRef.current) return;
|
|
154
|
+
// Which access protocol opened the session, and why. PACE is new here and
|
|
155
|
+
// reads over BAC by default, so this line is what distinguishes "the chip
|
|
156
|
+
// never offered PACE" from "our PACE was attempted and failed" while it
|
|
157
|
+
// is being proven against real documents. Dev builds only — it is
|
|
158
|
+
// diagnostics, never user-facing.
|
|
159
|
+
if (__DEV__) {
|
|
160
|
+
console.log(
|
|
161
|
+
`[kyc.nfc] session opened over ${result.chipAuth}` +
|
|
162
|
+
(result.paceOutcome ? ` (pace: ${result.paceOutcome}` : '') +
|
|
163
|
+
(result.paceDetail ? ` — ${result.paceDetail})` : result.paceOutcome ? ')' : ''),
|
|
164
|
+
);
|
|
165
|
+
}
|
|
154
166
|
store.getState().setChipData(result);
|
|
155
167
|
setResult(result);
|
|
156
168
|
setPhase('done');
|
|
@@ -33,22 +33,35 @@ export function QuestionnaireFieldView({
|
|
|
33
33
|
field,
|
|
34
34
|
value,
|
|
35
35
|
currencyValue,
|
|
36
|
+
detailValue,
|
|
36
37
|
error,
|
|
37
38
|
onChange,
|
|
38
39
|
onCurrencyChange,
|
|
40
|
+
onDetailChange,
|
|
39
41
|
}: {
|
|
40
42
|
field: FieldDef;
|
|
41
43
|
value: QuestionnaireAnswerValue | undefined;
|
|
42
44
|
/** money only: the `<key>_currency` companion answer. */
|
|
43
45
|
currencyValue?: string;
|
|
46
|
+
/** choice fields only: the `<key>_other` companion answer. */
|
|
47
|
+
detailValue?: string;
|
|
44
48
|
error?: string;
|
|
45
49
|
onChange: (value: QuestionnaireAnswerValue | undefined) => void;
|
|
46
50
|
onCurrencyChange: (currency: string) => void;
|
|
51
|
+
onDetailChange?: (detail: string | undefined) => void;
|
|
47
52
|
}): React.ReactElement {
|
|
48
53
|
const { colors } = useTheme();
|
|
49
54
|
|
|
50
55
|
const isPlainInput = field.type === 'text' || field.type === 'number';
|
|
51
56
|
|
|
57
|
+
// The chosen option that is not an answer on its own ("Other"). Covers both
|
|
58
|
+
// select (a single value) and multiselect (a list).
|
|
59
|
+
const detailOption = (field.options ?? []).find(
|
|
60
|
+
(o) =>
|
|
61
|
+
o.requiresDetail &&
|
|
62
|
+
(Array.isArray(value) ? value.includes(o.value) : value === o.value),
|
|
63
|
+
);
|
|
64
|
+
|
|
52
65
|
return (
|
|
53
66
|
<View style={{ marginBottom: spacing.lg }}>
|
|
54
67
|
<FieldLabel
|
|
@@ -143,6 +156,23 @@ export function QuestionnaireFieldView({
|
|
|
143
156
|
})
|
|
144
157
|
: null}
|
|
145
158
|
|
|
159
|
+
{/* Free text behind an "Other" choice. Always required once that option
|
|
160
|
+
is picked: an unexplained "Other" is the answer a compliance reviewer
|
|
161
|
+
most needs spelled out. */}
|
|
162
|
+
{detailOption ? (
|
|
163
|
+
<View style={{ marginTop: spacing.sm }}>
|
|
164
|
+
<MyazaInput
|
|
165
|
+
label={detailOption.detailLabel || 'Please specify'}
|
|
166
|
+
value={detailValue ?? ''}
|
|
167
|
+
maxLength={200}
|
|
168
|
+
placeholder={
|
|
169
|
+
detailOption.detailPlaceholder || `Tell us more about "${detailOption.label}"`
|
|
170
|
+
}
|
|
171
|
+
onChangeText={(text: string) => onDetailChange?.(text || undefined)}
|
|
172
|
+
/>
|
|
173
|
+
</View>
|
|
174
|
+
) : null}
|
|
175
|
+
|
|
146
176
|
{/* Inputs draw their own error; the rest need one underneath. */}
|
|
147
177
|
{error && !isPlainInput && field.type !== 'money' ? (
|
|
148
178
|
<MyazaText variant="bodySmall" color={colors.error} style={{ marginTop: spacing.xs }}>
|
|
@@ -4,7 +4,7 @@ import { View } from 'react-native';
|
|
|
4
4
|
import { spacing } from '../config/theme';
|
|
5
5
|
import { useKyc, useKycConfig, useKycStore } from '../components/runtime';
|
|
6
6
|
import { MyazaButton } from '../components/MyazaButton';
|
|
7
|
-
import { currencyKeyFor, validateQuestionnaire } from '../config/questionnaire';
|
|
7
|
+
import { currencyKeyFor, otherKeyFor, validateQuestionnaire } from '../config/questionnaire';
|
|
8
8
|
import { QuestionnaireFieldView } from './QuestionnaireField';
|
|
9
9
|
import type { QuestionnaireAnswerValue } from '../types/workflow';
|
|
10
10
|
|
|
@@ -63,9 +63,11 @@ export function QuestionnaireStep(): React.ReactElement {
|
|
|
63
63
|
field={field}
|
|
64
64
|
value={answers[field.key]}
|
|
65
65
|
currencyValue={answers[currencyKeyFor(field)] as string | undefined}
|
|
66
|
+
detailValue={answers[otherKeyFor(field)] as string | undefined}
|
|
66
67
|
error={errors[field.key] || undefined}
|
|
67
68
|
onChange={(value) => setAnswer(field.key, value)}
|
|
68
69
|
onCurrencyChange={(currency) => setAnswer(currencyKeyFor(field), currency)}
|
|
70
|
+
onDetailChange={(detail) => setAnswer(otherKeyFor(field), detail)}
|
|
69
71
|
/>
|
|
70
72
|
))}
|
|
71
73
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState } from 'react';
|
|
1
|
+
import React, { useMemo, useState } from 'react';
|
|
2
2
|
import { Image, View } from 'react-native';
|
|
3
3
|
|
|
4
4
|
import { spacing } from '../../config/theme';
|
|
@@ -8,7 +8,7 @@ import { MyazaButton } from '../../components/MyazaButton';
|
|
|
8
8
|
import { Icon } from '../../components/Icon';
|
|
9
9
|
import { useChipPortrait } from './useChipPortrait';
|
|
10
10
|
import { NfcScannedSummary } from './NfcScannedSummary';
|
|
11
|
-
import type
|
|
11
|
+
import { parseDg1, type EmrtdReadResult } from '../../emrtd';
|
|
12
12
|
import type { MrzScan } from '../../mrz/parse';
|
|
13
13
|
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
@@ -41,7 +41,12 @@ export function NfcSuccessPanel({
|
|
|
41
41
|
// component rejects) must degrade to the generic mark, not an empty circle.
|
|
42
42
|
const [portraitBroken, setPortraitBroken] = useState(false);
|
|
43
43
|
const portrait = portraitBroken ? null : portraitUri;
|
|
44
|
-
|
|
44
|
+
// Once the chip has been read, IT is the source — see emrtd/dg1.ts. The
|
|
45
|
+
// camera scan stays as the fallback for a chip whose DG1 we could not parse,
|
|
46
|
+
// so a read that otherwise worked still shows something.
|
|
47
|
+
const chip = useMemo(() => parseDg1(result.dg1), [result.dg1]);
|
|
48
|
+
const holder = chip ?? mrz;
|
|
49
|
+
const name = [holder?.firstName, holder?.lastName].filter(Boolean).join(' ');
|
|
45
50
|
|
|
46
51
|
return (
|
|
47
52
|
<View style={{ alignItems: 'center' }}>
|
|
@@ -132,12 +137,14 @@ export function NfcSuccessPanel({
|
|
|
132
137
|
) : null}
|
|
133
138
|
|
|
134
139
|
{/* What was read, shown back — the same card the pre-read screen uses, so
|
|
135
|
-
the user checks the same two fields against the printed page.
|
|
136
|
-
|
|
140
|
+
the user checks the same two fields against the printed page. Before
|
|
141
|
+
the read that card can only show the camera's guess; here it shows the
|
|
142
|
+
chip's own values, which is both correct and a stronger check. */}
|
|
143
|
+
{holder ? (
|
|
137
144
|
<>
|
|
138
145
|
<View style={{ height: spacing.lg }} />
|
|
139
146
|
<View style={{ alignSelf: 'stretch' }}>
|
|
140
|
-
<NfcScannedSummary scan={
|
|
147
|
+
<NfcScannedSummary scan={holder} />
|
|
141
148
|
</View>
|
|
142
149
|
</>
|
|
143
150
|
) : null}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// `utils/device-metadata.ts` and the Flutter SDK's `device_metadata_service.dart`.
|
|
5
5
|
|
|
6
6
|
import { OS } from '../utils/platform';
|
|
7
|
+
import { getStepLog } from '../lib/step-log';
|
|
7
8
|
|
|
8
9
|
export const SDK_TYPE = 'react-native' as const;
|
|
9
10
|
|
|
@@ -14,7 +15,7 @@ export type DeviceType = 'mobile' | 'tablet' | 'desktop' | 'unknown';
|
|
|
14
15
|
* Single source of truth for the SDK version — also used by `services/api.ts`
|
|
15
16
|
* for the `X-SDK-Version` header. Keep in sync with `package.json`.
|
|
16
17
|
*/
|
|
17
|
-
export const SDK_VERSION = '2.
|
|
18
|
+
export const SDK_VERSION = '2.3.0';
|
|
18
19
|
|
|
19
20
|
export interface ReactNativeDeviceMetadata {
|
|
20
21
|
sdkType: 'react-native';
|
|
@@ -181,5 +182,12 @@ export function collectDeviceMetadata(): ReactNativeDeviceMetadata {
|
|
|
181
182
|
meta.locale = locales[0]?.languageTag;
|
|
182
183
|
}
|
|
183
184
|
|
|
185
|
+
// Step journey recorded during the session — powers the dashboard's
|
|
186
|
+
// verification timeline. See lib/step-log.
|
|
187
|
+
const stepLog = getStepLog();
|
|
188
|
+
if (stepLog) {
|
|
189
|
+
(meta as ReactNativeDeviceMetadata & { stepLog?: unknown }).stepLog = stepLog;
|
|
190
|
+
}
|
|
191
|
+
|
|
184
192
|
return meta;
|
|
185
193
|
}
|
package/src/store/derive.ts
CHANGED
|
@@ -29,6 +29,13 @@ import type { KYCStep, SupportedCountry } from '../types/config';
|
|
|
29
29
|
import type { KycState } from './state';
|
|
30
30
|
|
|
31
31
|
export function livenessEnabled(state: KycState): boolean {
|
|
32
|
+
// Presence Intelligence off ⇒ no selfie step at all. This is the builder's
|
|
33
|
+
// "Presence Intelligence step" switch (`enableSelfie`), and it outranks the
|
|
34
|
+
// liveness-gesture question below: with no selfie there is nothing to run
|
|
35
|
+
// gestures against. Checked first for that reason, and because omitting it
|
|
36
|
+
// was why turning the step off in the workflow changed nothing here while it
|
|
37
|
+
// worked on web.
|
|
38
|
+
if (state.config.enableSelfie === false) return false;
|
|
32
39
|
// Consumer baseline: liveness is on unless explicitly disabled.
|
|
33
40
|
if (state.config.enableLiveness === false) return false;
|
|
34
41
|
const idType = state.selectedIdType;
|
package/src/store/kycStore.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
type KycStore,
|
|
28
28
|
} from './state';
|
|
29
29
|
import { nextStepAfter, nfcDecision, previousStepBefore } from './derive';
|
|
30
|
+
import { recordStep, resetStepLog } from '../lib/step-log';
|
|
30
31
|
import { buildVerifyRequest } from './submit';
|
|
31
32
|
import { applicantMediaCaptured, buildApplicantVerifyRequest } from './submitApplicant';
|
|
32
33
|
|
|
@@ -54,7 +55,7 @@ export function createKycStore(
|
|
|
54
55
|
const baseUrl = resolveBaseUrl(config.apiKey, config.devUrl);
|
|
55
56
|
const api = createKYCApi(baseUrl, config.apiKey);
|
|
56
57
|
|
|
57
|
-
|
|
58
|
+
const store = createStore<KycState>((set, get) => {
|
|
58
59
|
function emitStepChange(step: KYCStep): void {
|
|
59
60
|
config.onStepChange?.(step);
|
|
60
61
|
}
|
|
@@ -354,6 +355,11 @@ export function createKycStore(
|
|
|
354
355
|
},
|
|
355
356
|
|
|
356
357
|
reset() {
|
|
358
|
+
// Fresh step journey per session; the explicit record covers a store
|
|
359
|
+
// already sitting on 'consent' (the subscribe below only fires on
|
|
360
|
+
// change, and recordStep dedupes if it fires too).
|
|
361
|
+
resetStepLog();
|
|
362
|
+
recordStep('consent');
|
|
357
363
|
set({
|
|
358
364
|
currentStep: 'consent',
|
|
359
365
|
selectedCountry: null,
|
|
@@ -383,6 +389,24 @@ export function createKycStore(
|
|
|
383
389
|
},
|
|
384
390
|
};
|
|
385
391
|
});
|
|
392
|
+
|
|
393
|
+
// Step journey log — records every step the user reaches (the subscription
|
|
394
|
+
// catches ALL currentStep writes, whatever action made them; recordStep
|
|
395
|
+
// collapses consecutive duplicates). Rides the submission as
|
|
396
|
+
// metadata.device.stepLog for the dashboard timeline.
|
|
397
|
+
store.subscribe((s, prev) => {
|
|
398
|
+
if (s.currentStep !== prev.currentStep) recordStep(s.currentStep);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
// A fresh store IS a fresh session: the runtime provider creates one per
|
|
402
|
+
// modal launch and reset() is NOT part of the open path, so the journey
|
|
403
|
+
// starts here — and the opening step must be recorded explicitly (the
|
|
404
|
+
// subscription above only fires on CHANGE, and a new store already sits on
|
|
405
|
+
// 'consent').
|
|
406
|
+
resetStepLog();
|
|
407
|
+
recordStep(store.getState().currentStep);
|
|
408
|
+
|
|
409
|
+
return store;
|
|
386
410
|
}
|
|
387
411
|
|
|
388
412
|
// Re-export the flow helpers for tests + screens that need to reason about
|
package/src/types/config.ts
CHANGED
|
@@ -61,6 +61,9 @@ export type KYCStep =
|
|
|
61
61
|
// Client-side SDK config (MyazaKYC.show() / useMyazaKYC options)
|
|
62
62
|
// ---------------------------------------------------------------------------
|
|
63
63
|
|
|
64
|
+
/** How flow progress is drawn — see {@link MyazaKYCConfig.progressStyle}. */
|
|
65
|
+
export type ProgressStyle = 'steps' | 'bar';
|
|
66
|
+
|
|
64
67
|
export interface MyazaKYCConfig<C extends SupportedCountry = SupportedCountry> {
|
|
65
68
|
/**
|
|
66
69
|
* Bearer token. The key prefix is the single source of truth for the
|
|
@@ -203,6 +206,20 @@ export interface MyazaKYCConfig<C extends SupportedCountry = SupportedCountry> {
|
|
|
203
206
|
/** Show a light/dark mode toggle button inside the modal header. Default `true`. */
|
|
204
207
|
showThemeToggle?: boolean;
|
|
205
208
|
|
|
209
|
+
/**
|
|
210
|
+
* How progress through the flow is drawn in the header.
|
|
211
|
+
*
|
|
212
|
+
* • `'steps'` (default) — numbered circles, one per step, connected. Shows
|
|
213
|
+
* WHICH step you are on and how many there are, and collapses to a window
|
|
214
|
+
* when they no longer fit.
|
|
215
|
+
* • `'bar'` — a single thin bar pinned to the bottom edge of the header.
|
|
216
|
+
* Quieter, and unaffected by step count, so it suits long flows and hosts
|
|
217
|
+
* who would rather the chrome said less.
|
|
218
|
+
*
|
|
219
|
+
* Both convey the same fraction; the choice is how much room it takes.
|
|
220
|
+
*/
|
|
221
|
+
progressStyle?: ProgressStyle;
|
|
222
|
+
|
|
206
223
|
/**
|
|
207
224
|
* Hide the close (X) button and block all user-initiated dismissal of the
|
|
208
225
|
* sheet — the X button, Android hardware back, and the iOS swipe-down drag.
|
package/src/types/workflow.ts
CHANGED
|
@@ -82,6 +82,16 @@ export interface PhoneVerificationConfig {
|
|
|
82
82
|
export interface QuestionnaireFieldOption {
|
|
83
83
|
value: string;
|
|
84
84
|
label: string;
|
|
85
|
+
/**
|
|
86
|
+
* Marks a choice that is not an answer on its own — an "Other". Selecting it
|
|
87
|
+
* reveals a required free-text input, stored as the `<key>_other` companion
|
|
88
|
+
* answer (the same shape as a money field's `<key>_currency`).
|
|
89
|
+
*/
|
|
90
|
+
requiresDetail?: boolean;
|
|
91
|
+
/** Label for the detail input (default "Please specify"). */
|
|
92
|
+
detailLabel?: string;
|
|
93
|
+
/** Placeholder for the detail input (default `Tell us more about "<label>"`). */
|
|
94
|
+
detailPlaceholder?: string;
|
|
85
95
|
}
|
|
86
96
|
|
|
87
97
|
export interface QuestionnaireField {
|