@myazahq/kyc-sdk-react-native 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -2
- package/package.json +1 -1
- package/src/components/Icon.tsx +3 -0
- package/src/config/keyPeople.ts +10 -0
- package/src/screens/ApplicantRoleStep.tsx +1 -10
- package/src/screens/BusinessKeyPeopleStep.tsx +101 -45
- 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/services/deviceMetadata.ts +1 -1
package/README.md
CHANGED
|
@@ -133,6 +133,47 @@ autolinked via React Native / Expo autolinking — no manual linking required.
|
|
|
133
133
|
`children` (a string) to relabel it. For a fully custom trigger, use the
|
|
134
134
|
[`useMyazaKYC()` hook](#trigger-component--hook).
|
|
135
135
|
|
|
136
|
+
### Recommended — mount a workflow
|
|
137
|
+
|
|
138
|
+
Build the flow once in the Myaza dashboard as a **workflow**, then mount it by
|
|
139
|
+
id. The country, ID types, capture steps, add-ons, branding and copy all come
|
|
140
|
+
from the workflow, so changing the flow is a re-publish in the dashboard rather
|
|
141
|
+
than a new app build and an app-store review. See [Workflows](#workflows).
|
|
142
|
+
|
|
143
|
+
```tsx
|
|
144
|
+
import { MyazaKYC } from '@myazahq/kyc-sdk-react-native';
|
|
145
|
+
|
|
146
|
+
export default function VerifyScreen() {
|
|
147
|
+
return (
|
|
148
|
+
<MyazaKYC
|
|
149
|
+
apiKey="pk_live_xxx" // prefix selects the env: pk_test_ → sandbox
|
|
150
|
+
workflowId="wf_AbC123dEf456"
|
|
151
|
+
// Runtime data — a workflow is a shared template and cannot carry any of it.
|
|
152
|
+
userId="usr_123"
|
|
153
|
+
userData={{ firstName: 'Jane', lastName: 'Doe' }}
|
|
154
|
+
metadata={{ orderId: 'ord_456' }}
|
|
155
|
+
onSubmit={(submission) => console.log('Submitted!', submission.verificationId)}
|
|
156
|
+
onError={(err) => console.warn('SDK error:', err.code, err.message)}
|
|
157
|
+
onClose={() => console.log('Modal closed')}
|
|
158
|
+
>
|
|
159
|
+
Verify my identity
|
|
160
|
+
</MyazaKYC>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
**`userData` is worth passing.** It is the name you believe the user has, and it
|
|
166
|
+
is compared against the name read off their document — that comparison is what
|
|
167
|
+
produces `dataMatch` on the verification. It cannot live on the workflow:
|
|
168
|
+
`userId`, `userData` and `metadata` are per-user runtime values, and a workflow
|
|
169
|
+
is a template shared by every visitor, so these stay in code even when
|
|
170
|
+
everything else moves to the dashboard.
|
|
171
|
+
|
|
172
|
+
### Or configure everything in code
|
|
173
|
+
|
|
174
|
+
Skip the workflow and pass the flow's shape as props. Useful for a quick start
|
|
175
|
+
or a single fixed flow; anything you'd change later means shipping a new build.
|
|
176
|
+
|
|
136
177
|
```tsx
|
|
137
178
|
import { MyazaKYC } from '@myazahq/kyc-sdk-react-native';
|
|
138
179
|
|
|
@@ -235,8 +276,8 @@ An unrecognized or malformed key throws at setup (it never silently defaults).
|
|
|
235
276
|
|
|
236
277
|
## Workflows
|
|
237
278
|
|
|
238
|
-
|
|
239
|
-
reference it by id
|
|
279
|
+
The recommended integration (see [Usage](#usage)): build the flow in the Myaza
|
|
280
|
+
dashboard and reference it by id —
|
|
240
281
|
|
|
241
282
|
```tsx
|
|
242
283
|
<MyazaKYC apiKey="pk_live_xxx" workflowId="wf_abc123" userId="usr_123" />
|
package/package.json
CHANGED
package/src/components/Icon.tsx
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
Moon,
|
|
31
31
|
MoveLeft,
|
|
32
32
|
Nfc,
|
|
33
|
+
Pencil,
|
|
33
34
|
Smartphone,
|
|
34
35
|
RefreshCw,
|
|
35
36
|
ScanFace,
|
|
@@ -84,6 +85,7 @@ export type IconName =
|
|
|
84
85
|
| 'scan-line'
|
|
85
86
|
| 'scan-face'
|
|
86
87
|
| 'nfc'
|
|
88
|
+
| 'pencil'
|
|
87
89
|
| 'search'
|
|
88
90
|
| 'zap'
|
|
89
91
|
| 'link'
|
|
@@ -145,6 +147,7 @@ const ICONS: Record<IconName, LucideIcon> = {
|
|
|
145
147
|
'scan-line': ScanLine,
|
|
146
148
|
'scan-face': ScanFace,
|
|
147
149
|
nfc: Nfc,
|
|
150
|
+
pencil: Pencil,
|
|
148
151
|
search: Search,
|
|
149
152
|
zap: Zap,
|
|
150
153
|
'zap-off': ZapOff,
|
package/src/config/keyPeople.ts
CHANGED
|
@@ -106,6 +106,16 @@ export function keyPeoplePayload(
|
|
|
106
106
|
}));
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/** "Richard Ingwe" → "RI" — the avatar monogram used on person cards. */
|
|
110
|
+
export function initialsOf(name: string): string {
|
|
111
|
+
return name
|
|
112
|
+
.trim()
|
|
113
|
+
.split(/\s+/)
|
|
114
|
+
.slice(0, 2)
|
|
115
|
+
.map((t) => t[0]?.toUpperCase() ?? '')
|
|
116
|
+
.join('');
|
|
117
|
+
}
|
|
118
|
+
|
|
109
119
|
/** Split a typed full name into first/last (best-effort). */
|
|
110
120
|
export function splitFullName(name: string): { firstName?: string; lastName?: string } | undefined {
|
|
111
121
|
const trimmed = name.trim();
|
|
@@ -13,6 +13,7 @@ import { CountryFlag } from '../components/CountryFlag';
|
|
|
13
13
|
import {
|
|
14
14
|
KEY_PERSON_ROLE_LABELS,
|
|
15
15
|
KEY_PERSON_ROLES,
|
|
16
|
+
initialsOf,
|
|
16
17
|
isKeyPersonRowValid,
|
|
17
18
|
namesLooselyMatch,
|
|
18
19
|
} from '../config/keyPeople';
|
|
@@ -43,16 +44,6 @@ const APPLICANT_ROLE_LABELS: Record<ApplicantRole, string> = {
|
|
|
43
44
|
authorized_representative: 'Authorized representative',
|
|
44
45
|
};
|
|
45
46
|
|
|
46
|
-
/** "Richard Ingwe" → "RI" — the avatar monogram. */
|
|
47
|
-
function initialsOf(name: string): string {
|
|
48
|
-
return name
|
|
49
|
-
.trim()
|
|
50
|
-
.split(/\s+/)
|
|
51
|
-
.slice(0, 2)
|
|
52
|
-
.map((t) => t[0]?.toUpperCase() ?? '')
|
|
53
|
-
.join('');
|
|
54
|
-
}
|
|
55
|
-
|
|
56
47
|
export function ApplicantRoleStep(): React.ReactElement {
|
|
57
48
|
const store = useKycStore();
|
|
58
49
|
const config = useKycConfig();
|
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
import React from 'react';
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
2
|
import { View } from 'react-native';
|
|
3
3
|
|
|
4
4
|
import { radius, spacing } from '../config/theme';
|
|
5
5
|
import { useKyc, useKycConfig, useKycStore, useTheme } from '../components/runtime';
|
|
6
6
|
import { MyazaText } from '../components/Typography';
|
|
7
7
|
import { MyazaButton } from '../components/MyazaButton';
|
|
8
|
-
import {
|
|
8
|
+
import { KeyPersonCard } from './KeyPersonCard';
|
|
9
|
+
import { KeyPersonSheet } from './KeyPersonSheet';
|
|
9
10
|
import { keyPeopleMinEntries } from '../config/businessSteps';
|
|
10
11
|
import {
|
|
11
12
|
emptyKeyPerson,
|
|
12
13
|
invalidKeyPersonRows,
|
|
14
|
+
isKeyPersonRowBlank,
|
|
13
15
|
isKeyPersonRowValid,
|
|
14
16
|
MAX_KEY_PEOPLE_ROWS,
|
|
15
17
|
type KeyPersonEntry,
|
|
@@ -24,9 +26,10 @@ import type { KeyPersonRole } from '../types/business';
|
|
|
24
26
|
// `undisclosed`, which is a risk signal in its own right. So this screen is not
|
|
25
27
|
// merely data entry — what the applicant chooses to omit is evidence.
|
|
26
28
|
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
29
|
+
// The list stays a clean stack of compact summary cards; the FORM lives in the
|
|
30
|
+
// add/edit sheet (KeyPersonSheet). Tapping a card edits it; "Add a person"
|
|
31
|
+
// opens a fresh sheet. Removal is inside the edit sheet, behind a deliberate
|
|
32
|
+
// tap — never one stray touch on the list.
|
|
30
33
|
// ---------------------------------------------------------------------------
|
|
31
34
|
|
|
32
35
|
export const businessKeyPeopleMeta = {
|
|
@@ -35,45 +38,62 @@ export const businessKeyPeopleMeta = {
|
|
|
35
38
|
"List the company's directors and owners of 25% or more. Each will receive a link to verify their identity.",
|
|
36
39
|
};
|
|
37
40
|
|
|
41
|
+
type SheetState = { mode: 'add' } | { mode: 'edit'; index: number } | null;
|
|
42
|
+
|
|
38
43
|
export function BusinessKeyPeopleStep(): React.ReactElement {
|
|
39
44
|
const config = useKycConfig();
|
|
40
45
|
const store = useKycStore();
|
|
41
46
|
const { colors } = useTheme();
|
|
42
47
|
const rows = useKyc((s) => s.businessApplication.keyPeople);
|
|
43
48
|
const registryCountry = useKyc((s) => s.business.country);
|
|
49
|
+
const [sheet, setSheet] = useState<SheetState>(null);
|
|
44
50
|
|
|
45
51
|
const minEntries = keyPeopleMinEntries(config.business);
|
|
46
52
|
const validCount = rows.filter(isKeyPersonRowValid).length;
|
|
47
53
|
const invalidRows = invalidKeyPersonRows(rows);
|
|
54
|
+
const uboThreshold = config.business?.keyPeople?.ownershipThreshold ?? 25;
|
|
48
55
|
|
|
49
56
|
// Combined ownership above 100% is factually impossible — catch the typo
|
|
50
57
|
// here rather than shipping it into the registry cross-check as a doomed
|
|
51
58
|
// mismatch. Under 100% is fine (not every owner has to be listed).
|
|
52
|
-
const
|
|
59
|
+
const pctOf = (row: KeyPersonEntry): number => {
|
|
53
60
|
const n = Number(row.ownershipPct);
|
|
54
|
-
return row.ownershipPct.trim() !== '' && Number.isFinite(n) ?
|
|
55
|
-
}
|
|
61
|
+
return row.ownershipPct.trim() !== '' && Number.isFinite(n) ? n : 0;
|
|
62
|
+
};
|
|
63
|
+
const totalPct = rows.reduce((sum, row) => sum + pctOf(row), 0);
|
|
56
64
|
const overAllocated = totalPct > 100;
|
|
65
|
+
const fmtPct = (n: number): string => (Number.isInteger(n) ? String(n) : n.toFixed(1));
|
|
57
66
|
|
|
58
67
|
const canContinue = validCount >= minEntries && invalidRows.length === 0 && !overAllocated;
|
|
59
68
|
|
|
60
|
-
|
|
61
|
-
store.getState().setKeyPeople(rows.map((row, i) => (i === index ? { ...row, ...patch } : row)));
|
|
62
|
-
};
|
|
63
|
-
// New rows default to the business's registry country (picked on the
|
|
69
|
+
// New people default to the business's registry country (picked on the
|
|
64
70
|
// details step) — most directors are local, and a foreign one just switches
|
|
65
71
|
// theirs. Mirrors the web and Flutter SDKs.
|
|
66
72
|
const defaultCountry = registryCountry ?? config.business?.country ?? '';
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
73
|
+
|
|
74
|
+
const commit = (next: KeyPersonEntry[]): void => store.getState().setKeyPeople(next);
|
|
75
|
+
const handleSave = (entry: KeyPersonEntry): void => {
|
|
76
|
+
if (sheet?.mode === 'edit') {
|
|
77
|
+
commit(rows.map((row, i) => (i === sheet.index ? entry : row)));
|
|
78
|
+
} else {
|
|
79
|
+
commit([...rows, entry]);
|
|
80
|
+
}
|
|
81
|
+
setSheet(null);
|
|
82
|
+
};
|
|
83
|
+
const handleRemove = (): void => {
|
|
84
|
+
if (sheet?.mode === 'edit') commit(rows.filter((_, i) => i !== sheet.index));
|
|
85
|
+
setSheet(null);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Indexed access may be undefined under strict indexing — resolved once here
|
|
89
|
+
// so the sheet only ever mounts with a real entry.
|
|
90
|
+
const editEntry = sheet?.mode === 'edit' ? rows[sheet.index] : undefined;
|
|
71
91
|
|
|
72
92
|
const handleContinue = (): void => {
|
|
73
93
|
if (!canContinue) return;
|
|
74
94
|
// Half-typed rows are dropped rather than submitted: an entry the user
|
|
75
95
|
// abandoned is not a person they disclosed.
|
|
76
|
-
|
|
96
|
+
commit(rows.filter(isKeyPersonRowValid));
|
|
77
97
|
store.getState().nextStep();
|
|
78
98
|
};
|
|
79
99
|
|
|
@@ -98,6 +118,7 @@ export function BusinessKeyPeopleStep(): React.ReactElement {
|
|
|
98
118
|
borderRadius: radius.sm,
|
|
99
119
|
backgroundColor: colors.backgroundSecondary,
|
|
100
120
|
padding: spacing.md,
|
|
121
|
+
marginBottom: spacing.md,
|
|
101
122
|
}}
|
|
102
123
|
>
|
|
103
124
|
<MyazaText variant="bodySmall" color={colors.textSecondary}>
|
|
@@ -105,45 +126,80 @@ export function BusinessKeyPeopleStep(): React.ReactElement {
|
|
|
105
126
|
</MyazaText>
|
|
106
127
|
</View>
|
|
107
128
|
) : null}
|
|
108
|
-
|
|
129
|
+
|
|
130
|
+
{rows.map((row, index) =>
|
|
131
|
+
isKeyPersonRowBlank(row) ? null : (
|
|
132
|
+
<KeyPersonCard key={index} entry={row} onPress={() => setSheet({ mode: 'edit', index })} />
|
|
133
|
+
),
|
|
134
|
+
)}
|
|
135
|
+
{/* The cards and the add affordance are different things — give the
|
|
136
|
+
boundary some air (cards already carry a small bottom margin). */}
|
|
137
|
+
{rows.some((row) => !isKeyPersonRowBlank(row)) ? (
|
|
138
|
+
<View style={{ height: spacing.sm }} />
|
|
139
|
+
) : null}
|
|
140
|
+
|
|
141
|
+
{rows.length < MAX_KEY_PEOPLE_ROWS ? (
|
|
142
|
+
<MyazaButton
|
|
143
|
+
label="Add a person"
|
|
144
|
+
variant="outline"
|
|
145
|
+
leadingIcon="user-plus"
|
|
146
|
+
onPress={() => setSheet({ mode: 'add' })}
|
|
147
|
+
/>
|
|
148
|
+
) : (
|
|
149
|
+
<MyazaText variant="bodySmall" color={colors.textMuted} style={{ textAlign: 'center' }}>
|
|
150
|
+
{`You can list up to ${MAX_KEY_PEOPLE_ROWS} people here.`}
|
|
151
|
+
</MyazaText>
|
|
152
|
+
)}
|
|
153
|
+
{/* Breathing room between the add-person affordance and the summary bar —
|
|
154
|
+
matches the Flutter screen's SizedBox after its outline button. */}
|
|
155
|
+
<View style={{ height: spacing.md }} />
|
|
156
|
+
|
|
157
|
+
{/* Total-ownership summary at the DECISION point — the disabled Continue
|
|
158
|
+
button always explains itself, wherever the offending card is. */}
|
|
159
|
+
{totalPct > 0 ? (
|
|
109
160
|
<View
|
|
110
161
|
style={{
|
|
111
|
-
|
|
112
|
-
|
|
162
|
+
flexDirection: 'row',
|
|
163
|
+
justifyContent: 'space-between',
|
|
164
|
+
alignItems: 'center',
|
|
165
|
+
backgroundColor: overAllocated ? colors.errorBg : colors.backgroundSecondary,
|
|
113
166
|
borderRadius: radius.sm,
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
marginTop: hint ? spacing.sm : 0,
|
|
167
|
+
paddingHorizontal: spacing.md,
|
|
168
|
+
paddingVertical: spacing.sm + 4,
|
|
117
169
|
}}
|
|
118
170
|
>
|
|
119
|
-
<MyazaText variant="bodySmall" color={colors.error}>
|
|
120
|
-
|
|
171
|
+
<MyazaText variant="bodySmall" color={overAllocated ? colors.error : colors.textMuted}>
|
|
172
|
+
Total ownership listed
|
|
173
|
+
</MyazaText>
|
|
174
|
+
<MyazaText
|
|
175
|
+
variant="bodySmall"
|
|
176
|
+
color={overAllocated ? colors.error : colors.textDark}
|
|
177
|
+
style={{ fontWeight: '700' }}
|
|
178
|
+
>
|
|
179
|
+
{`${fmtPct(totalPct)}%`}
|
|
121
180
|
</MyazaText>
|
|
122
181
|
</View>
|
|
123
182
|
) : null}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
key={index}
|
|
128
|
-
row={row}
|
|
129
|
-
index={index}
|
|
130
|
-
onChange={(patch) => update(index, patch)}
|
|
131
|
-
onRemove={() => remove(index)}
|
|
132
|
-
uboThreshold={config.business?.keyPeople?.ownershipThreshold}
|
|
133
|
-
/>
|
|
134
|
-
))}
|
|
135
|
-
|
|
136
|
-
<View style={{ height: spacing.md }} />
|
|
137
|
-
{rows.length < MAX_KEY_PEOPLE_ROWS ? (
|
|
138
|
-
<MyazaButton label="Add a person" variant="outline" leadingIcon="user-plus" onPress={add} />
|
|
139
|
-
) : (
|
|
140
|
-
<MyazaText variant="bodySmall" color={colors.textMuted} style={{ textAlign: 'center' }}>
|
|
141
|
-
{`You can list up to ${MAX_KEY_PEOPLE_ROWS} people here.`}
|
|
183
|
+
{overAllocated ? (
|
|
184
|
+
<MyazaText variant="bodySmall" color={colors.error} style={{ marginTop: spacing.xs }}>
|
|
185
|
+
{`Together the percentages can't exceed 100% — reduce them by ${fmtPct(totalPct - 100)}%.`}
|
|
142
186
|
</MyazaText>
|
|
143
|
-
)}
|
|
187
|
+
) : null}
|
|
144
188
|
|
|
145
|
-
<View style={{ height: spacing.
|
|
189
|
+
<View style={{ height: spacing.md }} />
|
|
146
190
|
<MyazaButton label="Continue" onPress={handleContinue} disabled={!canContinue} />
|
|
191
|
+
|
|
192
|
+
{sheet && (sheet.mode === 'add' || editEntry) ? (
|
|
193
|
+
<KeyPersonSheet
|
|
194
|
+
mode={sheet.mode}
|
|
195
|
+
initial={editEntry ?? { ...emptyKeyPerson(), country: defaultCountry }}
|
|
196
|
+
uboThreshold={uboThreshold}
|
|
197
|
+
otherPctTotal={editEntry ? totalPct - pctOf(editEntry) : totalPct}
|
|
198
|
+
onSave={handleSave}
|
|
199
|
+
onRemove={sheet.mode === 'edit' ? handleRemove : undefined}
|
|
200
|
+
onClose={() => setSheet(null)}
|
|
201
|
+
/>
|
|
202
|
+
) : null}
|
|
147
203
|
</View>
|
|
148
204
|
);
|
|
149
205
|
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Pressable, View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { radius, spacing } from '../config/theme';
|
|
5
|
+
import { useTheme } from '../components/runtime';
|
|
6
|
+
import { MyazaText } from '../components/Typography';
|
|
7
|
+
import { Icon } from '../components/Icon';
|
|
8
|
+
import { CountryFlag } from '../components/CountryFlag';
|
|
9
|
+
import {
|
|
10
|
+
KEY_PERSON_ROLE_LABELS,
|
|
11
|
+
initialsOf,
|
|
12
|
+
isKeyPersonRowValid,
|
|
13
|
+
type KeyPersonEntry,
|
|
14
|
+
} from '../config/keyPeople';
|
|
15
|
+
import { regionCountryName } from '../config/regions';
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// One saved key person, summarised — monogram avatar with their ID-issuing
|
|
19
|
+
// country flag badged on its corner, name, role · ownership meta, and the
|
|
20
|
+
// email their invite will go to. The whole card opens the edit sheet (the
|
|
21
|
+
// chevron is the affordance); removal lives INSIDE that sheet, so a stray tap
|
|
22
|
+
// can never delete a person.
|
|
23
|
+
//
|
|
24
|
+
// Same visual language as the applicant step's "this is me" cards, so the two
|
|
25
|
+
// screens read as one system.
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
export function KeyPersonCard({
|
|
29
|
+
entry,
|
|
30
|
+
onPress,
|
|
31
|
+
}: {
|
|
32
|
+
entry: KeyPersonEntry;
|
|
33
|
+
onPress: () => void;
|
|
34
|
+
}): React.ReactElement {
|
|
35
|
+
const { colors } = useTheme();
|
|
36
|
+
const name = entry.name.trim() || 'Unnamed person';
|
|
37
|
+
const country = entry.country.trim() ? entry.country.trim().toUpperCase() : null;
|
|
38
|
+
const pct = entry.ownershipPct.trim();
|
|
39
|
+
// A row persisted by the old inline UI (or interrupted mid-edit) may be
|
|
40
|
+
// incomplete — the card says so instead of silently blocking Continue.
|
|
41
|
+
const incomplete = !isKeyPersonRowValid(entry);
|
|
42
|
+
|
|
43
|
+
const meta = [KEY_PERSON_ROLE_LABELS[entry.role], pct ? `${pct}% ownership` : null]
|
|
44
|
+
.filter(Boolean)
|
|
45
|
+
.join(' · ');
|
|
46
|
+
// The flag alone doesn't say WHICH country — spell it out, alongside the
|
|
47
|
+
// email their invite goes to.
|
|
48
|
+
const detail = [country ? regionCountryName(country) : null, entry.email.trim() || null]
|
|
49
|
+
.filter(Boolean)
|
|
50
|
+
.join(' · ');
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<Pressable
|
|
54
|
+
onPress={onPress}
|
|
55
|
+
accessibilityRole="button"
|
|
56
|
+
accessibilityLabel={`Edit ${name}`}
|
|
57
|
+
style={({ pressed }) => ({
|
|
58
|
+
flexDirection: 'row',
|
|
59
|
+
alignItems: 'center',
|
|
60
|
+
borderWidth: 1,
|
|
61
|
+
borderColor: incomplete ? colors.error : colors.border,
|
|
62
|
+
borderRadius: radius.sm,
|
|
63
|
+
backgroundColor: pressed ? colors.backgroundSecondary : colors.background,
|
|
64
|
+
padding: spacing.md - 2,
|
|
65
|
+
marginBottom: spacing.sm,
|
|
66
|
+
})}
|
|
67
|
+
>
|
|
68
|
+
<View
|
|
69
|
+
style={{
|
|
70
|
+
width: 40,
|
|
71
|
+
height: 40,
|
|
72
|
+
borderRadius: 20,
|
|
73
|
+
backgroundColor: colors.primary100,
|
|
74
|
+
alignItems: 'center',
|
|
75
|
+
justifyContent: 'center',
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
<MyazaText variant="bodySmall" color={colors.primary} style={{ fontWeight: '700' }}>
|
|
79
|
+
{initialsOf(name)}
|
|
80
|
+
</MyazaText>
|
|
81
|
+
{country ? (
|
|
82
|
+
<View
|
|
83
|
+
style={{
|
|
84
|
+
position: 'absolute',
|
|
85
|
+
bottom: -2,
|
|
86
|
+
right: -4,
|
|
87
|
+
borderRadius: 10,
|
|
88
|
+
borderWidth: 2,
|
|
89
|
+
borderColor: colors.background,
|
|
90
|
+
overflow: 'hidden',
|
|
91
|
+
}}
|
|
92
|
+
>
|
|
93
|
+
<CountryFlag country={country} size={16} />
|
|
94
|
+
</View>
|
|
95
|
+
) : null}
|
|
96
|
+
</View>
|
|
97
|
+
|
|
98
|
+
<View style={{ flex: 1, minWidth: 0, marginLeft: spacing.sm + 4 }}>
|
|
99
|
+
<MyazaText variant="bodyMedium" numberOfLines={1} style={{ fontWeight: '600' }}>
|
|
100
|
+
{name}
|
|
101
|
+
</MyazaText>
|
|
102
|
+
<MyazaText variant="bodySmall" color={colors.textMuted} numberOfLines={1} style={{ marginTop: 1 }}>
|
|
103
|
+
{meta}
|
|
104
|
+
</MyazaText>
|
|
105
|
+
{incomplete ? (
|
|
106
|
+
<MyazaText variant="bodySmall" color={colors.error} numberOfLines={1} style={{ marginTop: 1 }}>
|
|
107
|
+
Incomplete — tap to finish
|
|
108
|
+
</MyazaText>
|
|
109
|
+
) : detail !== '' ? (
|
|
110
|
+
<MyazaText variant="bodySmall" color={colors.textMuted} numberOfLines={1} style={{ marginTop: 1 }}>
|
|
111
|
+
{detail}
|
|
112
|
+
</MyazaText>
|
|
113
|
+
) : null}
|
|
114
|
+
</View>
|
|
115
|
+
|
|
116
|
+
<Icon name="pencil" size={16} color={colors.textMuted} />
|
|
117
|
+
</Pressable>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -14,7 +14,7 @@ export type DeviceType = 'mobile' | 'tablet' | 'desktop' | 'unknown';
|
|
|
14
14
|
* Single source of truth for the SDK version — also used by `services/api.ts`
|
|
15
15
|
* for the `X-SDK-Version` header. Keep in sync with `package.json`.
|
|
16
16
|
*/
|
|
17
|
-
export const SDK_VERSION = '2.
|
|
17
|
+
export const SDK_VERSION = '2.2.0';
|
|
18
18
|
|
|
19
19
|
export interface ReactNativeDeviceMetadata {
|
|
20
20
|
sdkType: 'react-native';
|