@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 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
- Instead of configuring the flow in code, build it in the Myaza dashboard and
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myazahq/kyc-sdk-react-native",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Myaza KYC SDK for React Native (Expo) — ID verification, liveness detection, and document capture",
5
5
  "author": "Flitstack Technologies Inc.",
6
6
  "repository": {
@@ -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,
@@ -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 { BusinessKeyPersonRow } from './BusinessKeyPersonRow';
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
- // Layout, copy and per-row design mirror the web SDK's BusinessKeyPeopleStep
28
- // (and Flutter's screen) 1:1 — the description lives in the step HEADER, the
29
- // hints are dashed cards, and each person is a bordered card with selects.
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 totalPct = rows.reduce((sum, row) => {
59
+ const pctOf = (row: KeyPersonEntry): number => {
53
60
  const n = Number(row.ownershipPct);
54
- return row.ownershipPct.trim() !== '' && Number.isFinite(n) ? sum + n : sum;
55
- }, 0);
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
- const update = (index: number, patch: Partial<KeyPersonEntry>): void => {
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
- const add = (): void =>
68
- store.getState().setKeyPeople([...rows, { ...emptyKeyPerson(), country: defaultCountry }]);
69
- const remove = (index: number): void =>
70
- store.getState().setKeyPeople(rows.filter((_, i) => i !== index));
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
- store.getState().setKeyPeople(rows.filter(isKeyPersonRowValid));
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
- {overAllocated ? (
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
- borderWidth: 1,
112
- borderColor: `${colors.error}4D`,
162
+ flexDirection: 'row',
163
+ justifyContent: 'space-between',
164
+ alignItems: 'center',
165
+ backgroundColor: overAllocated ? colors.errorBg : colors.backgroundSecondary,
113
166
  borderRadius: radius.sm,
114
- backgroundColor: colors.errorBg,
115
- padding: spacing.md,
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
- {`The ownership percentages add up to ${totalPct}% — together they can't exceed 100%.`}
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
- {rows.map((row, index) => (
126
- <BusinessKeyPersonRow
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.sm }} />
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 { Pressable, View } from 'react-native';
2
+ import { View } from 'react-native';
3
3
 
4
- import { radius, spacing } from '../config/theme';
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
- // One editable director/owner row — mirrors the web SDK's BusinessKeyPersonRow
23
- // and the Flutter row 1:1: bordered card, "PERSON N" header with remove, then
24
- // full name → role (select) → ownership % → country (full ISO select — a key
25
- // person's ID can be issued anywhere, not just the registry country) → email.
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 row's role is an ownership one (% is only meaningful then). */
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 rows. */
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 BusinessKeyPersonRow({
45
- row,
46
- index,
41
+ export function KeyPersonForm({
42
+ entry,
47
43
  onChange,
48
- onRemove,
49
44
  uboThreshold = 25,
45
+ combinedPctError = null,
50
46
  }: {
51
- row: KeyPersonEntry;
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 = row.name !== '' && row.name.trim().length < 2;
63
- const emailInvalid = row.email.trim() !== '' && !isValidContactEmail(row.email.trim());
64
- const pct = row.ownershipPct.trim();
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 && row.role !== 'beneficial_owner';
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={row.name}
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={row.role}
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={row.ownershipPct}
103
+ value={entry.ownershipPct}
129
104
  onChangeText={(ownershipPct) => onChange({ ownershipPct })}
130
- placeholder={isOwnerRole(row.role) ? 'e.g. 60' : '—'}
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.' : null}
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 <MyazaText variant="bodySmall" color={colors.textSecondary}>(where their ID was issued)</MyazaText>
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={row.country || null}
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={row.email}
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.1.0';
17
+ export const SDK_VERSION = '2.2.0';
18
18
 
19
19
  export interface ReactNativeDeviceMetadata {
20
20
  sdkType: 'react-native';