@myazahq/kyc-sdk-react-native 2.3.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/android/build.gradle +35 -3
  2. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +75 -0
  3. package/ios/HybridMyazaFaceDetector.swift +8 -0
  4. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.cpp +23 -0
  5. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.hpp +2 -0
  6. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaFaceDetectorSpec.kt +9 -0
  7. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.cpp +8 -0
  8. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.hpp +43 -0
  9. package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.hpp +17 -0
  10. package/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
  11. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec.swift +2 -0
  12. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec_cxx.swift +31 -0
  13. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.cpp +2 -0
  14. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.hpp +3 -0
  15. package/package.json +1 -2
  16. package/src/MyazaKYC.tsx +12 -0
  17. package/src/components/BrandBar.tsx +137 -0
  18. package/src/components/DashedBorder.tsx +55 -0
  19. package/src/components/DialCodePicker.tsx +25 -24
  20. package/src/components/DocumentReviewSide.tsx +34 -14
  21. package/src/components/Icon.tsx +14 -0
  22. package/src/components/KycSheet.tsx +39 -140
  23. package/src/components/MediaSourceSheet.tsx +7 -37
  24. package/src/components/MultiIdProgress.tsx +107 -0
  25. package/src/components/MyazaDateField.tsx +6 -20
  26. package/src/components/MyazaInput.tsx +23 -4
  27. package/src/components/MyazaSelect.tsx +17 -40
  28. package/src/components/OwnershipSlider.tsx +102 -0
  29. package/src/components/PhoneNumberInput.tsx +37 -4
  30. package/src/components/SandboxBanner.tsx +92 -0
  31. package/src/components/StaggerIn.tsx +63 -0
  32. package/src/components/StepHeader.tsx +15 -2
  33. package/src/components/StepView.tsx +26 -0
  34. package/src/components/glass/ChromeGlass.tsx +65 -0
  35. package/src/components/glass/FloatingSheet.tsx +242 -0
  36. package/src/components/glass/GlassSheet.tsx +50 -0
  37. package/src/components/glass/GlassSurface.tsx +31 -3
  38. package/src/components/stepHeaderMeta.tsx +3 -1
  39. package/src/components/viewfinder/ImmersiveBottomBar.tsx +28 -17
  40. package/src/components/viewfinder/ImmersiveControls.tsx +26 -14
  41. package/src/components/viewfinder/ViewfinderControls.tsx +29 -7
  42. package/src/config/business.ts +6 -6
  43. package/src/config/businessDetailsValidity.ts +52 -0
  44. package/src/config/businessPrefill.ts +72 -0
  45. package/src/config/businessSteps.ts +14 -3
  46. package/src/config/keyPeople.ts +111 -7
  47. package/src/config/keyPeopleCorporate.ts +19 -0
  48. package/src/config/keyPeopleOwnerTypes.ts +12 -0
  49. package/src/config/keyPeopleOwners.ts +26 -0
  50. package/src/config/keyPeoplePrefill.ts +138 -0
  51. package/src/config/keyPeopleSectionDefs.ts +65 -0
  52. package/src/config/keyPeopleSections.ts +167 -0
  53. package/src/config/registrationHint.ts +1 -1
  54. package/src/config/stepOrder.ts +24 -4
  55. package/src/config/theme.ts +8 -2
  56. package/src/config/website.ts +36 -0
  57. package/src/config/workflowMerge.ts +5 -0
  58. package/src/emrtd/activeAuth.ts +100 -0
  59. package/src/emrtd/extras.ts +76 -0
  60. package/src/emrtd/files.ts +5 -0
  61. package/src/emrtd/index.ts +1 -0
  62. package/src/emrtd/read.ts +8 -1
  63. package/src/emrtd/session.ts +41 -0
  64. package/src/emrtd/stages.ts +11 -0
  65. package/src/lib/contact-recovery.ts +44 -0
  66. package/src/lib/multi-id.ts +210 -0
  67. package/src/lib/resubmit.ts +130 -0
  68. package/src/lib/screen-corners.ts +50 -0
  69. package/src/lib/step-log.ts +24 -3
  70. package/src/lib/use-multi-id-plan.ts +29 -0
  71. package/src/liveness/useModelReady.ts +65 -0
  72. package/src/liveness/visionCameraFaceDetector.ts +35 -0
  73. package/src/screens/ApplicantRoleStep.tsx +9 -5
  74. package/src/screens/BusinessCheckPanel.tsx +89 -0
  75. package/src/screens/BusinessDetailsFields.tsx +109 -0
  76. package/src/screens/BusinessDetailsStep.tsx +131 -145
  77. package/src/screens/BusinessDocumentSlot.tsx +2 -3
  78. package/src/screens/BusinessDocumentsStep.tsx +1 -1
  79. package/src/screens/BusinessKeyPeopleStep.tsx +103 -71
  80. package/src/screens/BusinessPickedCard.tsx +86 -0
  81. package/src/screens/BusinessPickedSection.tsx +51 -0
  82. package/src/screens/BusinessRegistryPickers.tsx +67 -0
  83. package/src/screens/BusinessSandboxToggle.tsx +114 -0
  84. package/src/screens/BusinessSearch.tsx +199 -0
  85. package/src/screens/BusinessSearchResults.tsx +115 -0
  86. package/src/screens/CompanyInfoFields.tsx +86 -51
  87. package/src/screens/ConsentStep.tsx +31 -0
  88. package/src/screens/ContactDestinationField.tsx +1 -0
  89. package/src/screens/ContactVerificationStep.tsx +34 -3
  90. package/src/screens/IdTypeStep.tsx +12 -5
  91. package/src/screens/KeepLinksSheet.tsx +88 -0
  92. package/src/screens/KeyPeopleAwaitCard.tsx +204 -0
  93. package/src/screens/KeyPeopleAwaitList.tsx +126 -0
  94. package/src/screens/KeyPeoplePending.tsx +154 -0
  95. package/src/screens/KeyPeopleSection.tsx +162 -0
  96. package/src/screens/KeyPeopleSectionsList.tsx +103 -0
  97. package/src/screens/KeyPeopleUboExemption.tsx +72 -0
  98. package/src/screens/KeyPersonCard.tsx +36 -4
  99. package/src/screens/KeyPersonForm.tsx +195 -36
  100. package/src/screens/KeyPersonKindToggle.tsx +68 -0
  101. package/src/screens/KeyPersonOwners.tsx +109 -0
  102. package/src/screens/KeyPersonRoleChips.tsx +68 -0
  103. package/src/screens/KeyPersonSheet.tsx +92 -108
  104. package/src/screens/LivenessStep.tsx +18 -0
  105. package/src/screens/NfcStep.tsx +14 -0
  106. package/src/screens/ProofOfAddressStep.tsx +6 -3
  107. package/src/screens/SubmittedStep.tsx +66 -10
  108. package/src/screens/nfc/NfcReadProgress.tsx +1 -0
  109. package/src/screens/useAwaitingPeople.ts +90 -0
  110. package/src/services/api-types.ts +158 -2
  111. package/src/services/api-verify-types.ts +53 -1
  112. package/src/services/api.ts +104 -0
  113. package/src/services/deviceMetadata.ts +1 -1
  114. package/src/services/errors.ts +10 -2
  115. package/src/services/workflowGate.ts +1 -0
  116. package/src/specs/MyazaFaceDetector.nitro.ts +29 -0
  117. package/src/store/businessCheck.ts +113 -0
  118. package/src/store/derive.ts +15 -0
  119. package/src/store/kycStore.ts +241 -14
  120. package/src/store/serverConfig.ts +2 -0
  121. package/src/store/session.ts +208 -0
  122. package/src/store/state.ts +155 -2
  123. package/src/store/submit.ts +63 -13
  124. package/src/store/submitApplicant.ts +5 -11
  125. package/src/types/business.ts +23 -2
  126. package/src/types/config.ts +29 -3
  127. package/src/types/verification.ts +1 -1
  128. package/src/types/workflow.ts +15 -0
  129. package/src/screens/KeyPeopleInviteLinks.tsx +0 -239
@@ -1,27 +1,34 @@
1
- import React, { useMemo } from 'react';
2
- import { View } from 'react-native';
1
+ import React, { useMemo, useState } from 'react';
2
+ import { Pressable, View } from 'react-native';
3
3
 
4
- import { spacing } from '../config/theme';
4
+ import { radius, spacing } from '../config/theme';
5
5
  import { useTheme } from '../components/runtime';
6
6
  import { MyazaText } from '../components/Typography';
7
7
  import { MyazaInput } from '../components/MyazaInput';
8
- import { MyazaSelect } from '../components/MyazaSelect';
8
+ import { Icon } from '../components/Icon';
9
+ import { OwnershipSlider } from '../components/OwnershipSlider';
9
10
  import { CountryField } from '../components/CountryField';
10
11
  import type { DialCodeOption } from '../components/DialCodePicker';
11
12
  import { ALL_REGION_CODES, regionCountryName } from '../config/regions';
12
13
  import { isValidContactEmail } from '../config/contact';
14
+ import { rowNeedsEmail, type KeyPersonEntry } from '../config/keyPeople';
13
15
  import {
14
- KEY_PERSON_ROLES,
15
- KEY_PERSON_ROLE_LABELS,
16
- type KeyPersonEntry,
17
- } from '../config/keyPeople';
16
+ primaryRole,
17
+ rolesOf,
18
+ type KeyPeopleSection as SectionKey,
19
+ } from '../config/keyPeopleSections';
18
20
  import type { KeyPersonRole } from '../types/business';
21
+ import { KeyPersonKindToggle } from './KeyPersonKindToggle';
22
+ import { KeyPersonOwners } from './KeyPersonOwners';
23
+ import { KeyPersonRoleChips } from './KeyPersonRoleChips';
19
24
 
20
25
  // ---------------------------------------------------------------------------
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.
26
+ // The key-person FIELDS, scoped by the SECTION that opened the sheet: the
27
+ // section already said what this person is, so no coarse role dropdown — the
28
+ // UBO form asks name/stake/country/email, the shareholder form adds the
29
+ // individual-or-company toggle, and the representative form picks between the
30
+ // real classifications (chips) with the human nuance ("CFO, Board Member")
31
+ // captured as a free-text title. Mirrors the web SDK's KeyPersonForm 1:1.
25
32
  // ---------------------------------------------------------------------------
26
33
 
27
34
  /** Whether the role is an ownership one (% is only meaningful then). */
@@ -40,12 +47,22 @@ function allCountryOptions(): DialCodeOption[] {
40
47
 
41
48
  export function KeyPersonForm({
42
49
  entry,
50
+ section,
51
+ corporateKyb = false,
43
52
  onChange,
44
53
  uboThreshold = 25,
45
54
  combinedPctError = null,
55
+ emailRequiredFor,
46
56
  }: {
47
57
  entry: KeyPersonEntry;
58
+ /** The section whose add-tile or card opened the sheet. */
59
+ section: SectionKey;
60
+ /** The workflow sends corporate shareholders their own KYB application -
61
+ * the callout must tell that truth instead of the screening-only one. */
62
+ corporateKyb?: boolean;
48
63
  onChange: (patch: Partial<KeyPersonEntry>) => void;
64
+ /** Roles whose email is mandatory (they are sent a verification link). */
65
+ emailRequiredFor?: ReadonlySet<KeyPersonRole>;
49
66
  /** Ownership % at/above which the server treats a person as a beneficial
50
67
  * owner (the workflow's `keyPeople.ownershipThreshold`, default 25). */
51
68
  uboThreshold?: number;
@@ -62,47 +79,97 @@ export function KeyPersonForm({
62
79
 
63
80
  const nameInvalid = entry.name !== '' && entry.name.trim().length < 2;
64
81
  const emailInvalid = entry.email.trim() !== '' && !isValidContactEmail(entry.email.trim());
82
+ const needsEmail = rowNeedsEmail(entry, emailRequiredFor ?? new Set());
83
+ // COLLAPSED by default so the company sheet stays five fields; open when
84
+ // owners already exist (a restored draft, a look-through find). The
85
+ // capability is one tap away, not one field more. Mirrors the web SDK.
86
+ const [showOwners, setShowOwners] = useState((entry.owners?.length ?? 0) > 0);
65
87
  const pct = entry.ownershipPct.trim();
66
88
  const pctNum = Number(pct);
67
89
  const pctInvalid = pct !== '' && (!Number.isFinite(pctNum) || pctNum < 0 || pctNum > 100);
68
90
  // Surface the regulatory consequence as feedback: at/above the threshold the
69
91
  // server escalates this person to a beneficial owner regardless of the role
70
92
  // picked. Quiet when they already chose UBO — nothing new to say.
93
+ const corp = entry.isCorporate;
94
+ const roles = rolesOf(entry);
71
95
  const uboHint =
72
- !pctInvalid && pct !== '' && pctNum >= uboThreshold && entry.role !== 'beneficial_owner';
96
+ !corp && !pctInvalid && pct !== '' && pctNum >= uboThreshold && !roles.includes('beneficial_owner');
97
+ const belowThresholdNote =
98
+ section === 'ubos' && !pctInvalid && pct !== '' && pctNum > 0 && pctNum < uboThreshold;
99
+
100
+ const setRoles = (next: KeyPersonRole[]): void =>
101
+ onChange({ roles: next, role: primaryRole(next) });
73
102
 
74
103
  return (
75
104
  <View>
105
+ {/* Only shareholders can be a company: a beneficial owner is a natural
106
+ person in every regime that defines one, and a representative form
107
+ is about the people who act. */}
108
+ {section === 'shareholders' ? (
109
+ <>
110
+ <KeyPersonKindToggle
111
+ isCorporate={corp}
112
+ onChange={(isCorporate) =>
113
+ // Beneficial ownership is a claim about a person, so switching
114
+ // to a company reads the role set down rather than leaving an
115
+ // impossible one.
116
+ onChange({
117
+ isCorporate,
118
+ ...(isCorporate
119
+ ? {
120
+ roles: roles.map((r) => (r === 'beneficial_owner' ? 'shareholder' : r)),
121
+ role: primaryRole(
122
+ roles.map((r) => (r === 'beneficial_owner' ? 'shareholder' : r)),
123
+ ),
124
+ }
125
+ : { registrationNumber: '', owners: [] }),
126
+ })
127
+ }
128
+ />
129
+ <View style={{ height: spacing.sm }} />
130
+ </>
131
+ ) : null}
76
132
  <MyazaInput
77
- label="Full name"
133
+ label={corp ? 'Company name' : 'Full name'}
78
134
  value={entry.name}
79
135
  onChangeText={(name) => onChange({ name })}
80
- placeholder="e.g. Bola Owner"
81
- // It's a person's name — start every word capitalized.
136
+ placeholder={corp ? 'e.g. Acme Holdings Ltd' : 'e.g. Bola Owner'}
137
+ // It's a name — start every word capitalized.
82
138
  autoCapitalize="words"
83
- error={nameInvalid ? 'Enter the person’s full name.' : null}
139
+ error={
140
+ nameInvalid
141
+ ? `Enter the ${corp ? 'registered company name' : 'person’s full name'}.`
142
+ : null
143
+ }
84
144
  />
85
145
 
86
- <View style={{ height: spacing.sm }} />
87
- <MyazaText variant="bodySmall" style={{ fontWeight: '600', marginBottom: spacing.xs }}>
88
- Role
89
- </MyazaText>
90
- <MyazaSelect
91
- value={entry.role}
92
- sheetTitle="Role"
93
- options={KEY_PERSON_ROLES.map((role) => ({
94
- value: role,
95
- label: KEY_PERSON_ROLE_LABELS[role],
96
- }))}
97
- onChange={(role) => onChange({ role })}
98
- />
146
+ {section === 'representatives' ? (
147
+ <>
148
+ <View style={{ height: spacing.sm }} />
149
+ <KeyPersonRoleChips roles={roles} onRoles={setRoles} />
150
+ </>
151
+ ) : null}
152
+
153
+ {!corp ? (
154
+ <>
155
+ <View style={{ height: spacing.sm }} />
156
+ <MyazaInput
157
+ label="Position or title (optional)"
158
+ value={entry.title}
159
+ onChangeText={(title) => onChange({ title })}
160
+ placeholder="e.g. CFO, Board Member"
161
+ autoCapitalize="words"
162
+ autoCorrect={false}
163
+ />
164
+ </>
165
+ ) : null}
99
166
 
100
167
  <View style={{ height: spacing.sm }} />
101
168
  <MyazaInput
102
169
  label="Ownership % (optional)"
103
170
  value={entry.ownershipPct}
104
171
  onChangeText={(ownershipPct) => onChange({ ownershipPct })}
105
- placeholder={isOwnerRole(entry.role) ? 'e.g. 60' : '—'}
172
+ placeholder="0"
106
173
  keyboardType="decimal-pad"
107
174
  suffix={
108
175
  <MyazaText variant="bodySmall" color={colors.textMuted}>
@@ -113,15 +180,37 @@ export function KeyPersonForm({
113
180
  helper={
114
181
  uboHint
115
182
  ? `At ${uboThreshold}% or more, this person counts as a beneficial owner.`
116
- : undefined
183
+ : belowThresholdNote
184
+ ? `Below ${uboThreshold}% they will be recorded as a shareholder.`
185
+ : undefined
117
186
  }
118
187
  />
188
+ <View style={{ height: spacing.xs }} />
189
+ {/* The slider rests at 0 for an undeclared stake; only a drag writes a
190
+ value, so an untouched slider still submits "not declared". */}
191
+ <OwnershipSlider
192
+ value={pct !== '' && Number.isFinite(Number(pct)) ? Number(pct) : 0}
193
+ onChange={(next) => onChange({ ownershipPct: String(next) })}
194
+ />
195
+
196
+ {corp ? (
197
+ <>
198
+ <View style={{ height: spacing.sm }} />
199
+ <MyazaInput
200
+ label="Registration number (optional)"
201
+ value={entry.registrationNumber}
202
+ onChangeText={(registrationNumber) => onChange({ registrationNumber })}
203
+ placeholder="e.g. RC123456"
204
+ autoCapitalize="characters"
205
+ />
206
+ </>
207
+ ) : null}
119
208
 
120
209
  <View style={{ height: spacing.sm }} />
121
- <MyazaText variant="bodySmall" style={{ fontWeight: '600', marginBottom: spacing.xs }}>
210
+ <MyazaText variant="label" style={{ marginBottom: spacing.sm }}>
122
211
  Country{' '}
123
- <MyazaText variant="bodySmall" color={colors.textSecondary}>
124
- (where their ID was issued)
212
+ <MyazaText variant="bodyMedium" color={colors.textSecondary}>
213
+ {corp ? '(of incorporation)' : '(where their ID was issued)'}
125
214
  </MyazaText>
126
215
  </MyazaText>
127
216
  {/* The SAME sheet as the phone field's dial-code picker (keyboard-aware,
@@ -133,16 +222,86 @@ export function KeyPersonForm({
133
222
  onChange={(country) => onChange({ country })}
134
223
  />
135
224
 
225
+ {corp && !showOwners ? (
226
+ <>
227
+ <View style={{ height: spacing.sm }} />
228
+ <Pressable
229
+ accessibilityRole="button"
230
+ onPress={() => setShowOwners(true)}
231
+ hitSlop={8}
232
+ style={{ flexDirection: 'row', alignItems: 'center', gap: 6, alignSelf: 'flex-start' }}
233
+ >
234
+ <Icon name="chevron-right" size={16} color={colors.textSecondary} />
235
+ <MyazaText
236
+ variant="bodyMedium"
237
+ color={colors.textSecondary}
238
+ style={{ fontWeight: '500' }}
239
+ >
240
+ Add the people who own it (optional)
241
+ </MyazaText>
242
+ </Pressable>
243
+ </>
244
+ ) : null}
245
+ {corp && showOwners ? (
246
+ <>
247
+ <View style={{ height: spacing.sm }} />
248
+ <KeyPersonOwners
249
+ owners={entry.owners ?? []}
250
+ companyName={entry.name}
251
+ onChange={(owners) => onChange({ owners })}
252
+ />
253
+ </>
254
+ ) : null}
255
+
136
256
  <View style={{ height: spacing.sm }} />
137
257
  <MyazaInput
138
- label="Email (optional — used to send their verification link)"
258
+ label={
259
+ needsEmail
260
+ ? 'Email *'
261
+ : corp
262
+ ? 'Email (optional)'
263
+ : 'Email (optional, used to send their verification link)'
264
+ }
139
265
  value={entry.email}
140
266
  onChangeText={(email) => onChange({ email })}
141
267
  placeholder="name@company.com"
142
268
  keyboardType="email-address"
143
269
  autoCapitalize="none"
144
270
  error={emailInvalid ? 'Enter a valid email address.' : null}
271
+ helper={
272
+ needsEmail && entry.email.trim() === ''
273
+ ? 'Required: this is how they receive their own verification link.'
274
+ : undefined
275
+ }
145
276
  />
277
+
278
+ {/* What actually happens to a company on this list, stated as a callout
279
+ rather than buried in a field helper, because it answers the question
280
+ every applicant has at this exact point ("is the company going to be
281
+ asked for a selfie?"). Deliberately OUR facts, not a promise of a
282
+ nested KYB we do not run. Mirrors the web SDK's callout. */}
283
+ {corp ? (
284
+ <View
285
+ style={{
286
+ marginTop: spacing.md,
287
+ flexDirection: 'row',
288
+ alignItems: 'flex-start',
289
+ gap: 10,
290
+ borderRadius: radius.sm,
291
+ backgroundColor: colors.primary50,
292
+ padding: 12,
293
+ }}
294
+ >
295
+ <View style={{ marginTop: 2 }}>
296
+ <Icon name="info" size={16} color={colors.primary} />
297
+ </View>
298
+ <MyazaText variant="bodyMedium" color={colors.textSecondary} style={{ flex: 1 }}>
299
+ {corporateKyb
300
+ ? 'This company will need its own KYB verification: it receives a link to a business application of its own, where the people who own it are identified. We also screen it against sanctions lists.'
301
+ : 'A company is never asked to verify an identity. We check it against sanctions lists, and the people who own it are reviewed separately, so list its owners above if you know them.'}
302
+ </MyazaText>
303
+ </View>
304
+ ) : null}
146
305
  </View>
147
306
  );
148
307
  }
@@ -0,0 +1,68 @@
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
+
8
+ // Person or company.
9
+ //
10
+ // The first question on the form, because it changes what the rest of it asks:
11
+ // a company has a registration number rather than an ID country, and no
12
+ // identity of its own to verify. Left unasked, a limited company was collected
13
+ // as a person, escalated to beneficial owner (which by definition means a
14
+ // natural person), and sent a link to take a selfie.
15
+
16
+ export function KeyPersonKindToggle({
17
+ isCorporate,
18
+ onChange,
19
+ }: {
20
+ isCorporate: boolean;
21
+ onChange: (isCorporate: boolean) => void;
22
+ }): React.ReactElement {
23
+ const { colors } = useTheme();
24
+ const options: Array<{ value: boolean; label: string }> = [
25
+ { value: false, label: 'A person' },
26
+ { value: true, label: 'A company' },
27
+ ];
28
+
29
+ return (
30
+ <View>
31
+ <MyazaText variant="bodySmall" style={{ fontWeight: '600', marginBottom: spacing.xs }}>
32
+ Who is this?
33
+ </MyazaText>
34
+ <View style={{ flexDirection: 'row', gap: spacing.sm }}>
35
+ {options.map(({ value, label }) => {
36
+ const selected = isCorporate === value;
37
+ return (
38
+ <Pressable
39
+ key={label}
40
+ accessibilityRole="radio"
41
+ accessibilityState={{ selected }}
42
+ accessibilityLabel={label}
43
+ onPress={() => onChange(value)}
44
+ style={{
45
+ flex: 1,
46
+ height: 44,
47
+ alignItems: 'center',
48
+ justifyContent: 'center',
49
+ borderRadius: radius.lg,
50
+ borderWidth: 1,
51
+ borderColor: selected ? colors.primary : colors.border,
52
+ backgroundColor: selected ? `${colors.primary}1A` : 'transparent',
53
+ }}
54
+ >
55
+ <MyazaText
56
+ variant="bodySmall"
57
+ style={{ fontWeight: '600' }}
58
+ color={selected ? colors.primary : colors.textSecondary}
59
+ >
60
+ {label}
61
+ </MyazaText>
62
+ </Pressable>
63
+ );
64
+ })}
65
+ </View>
66
+ </View>
67
+ );
68
+ }
@@ -0,0 +1,109 @@
1
+ import React from 'react';
2
+ import { Pressable, View } from 'react-native';
3
+
4
+ import { spacing, radius } from '../config/theme';
5
+ import { useTheme } from '../components/runtime';
6
+ import { MyazaText } from '../components/Typography';
7
+ import { MyazaInput } from '../components/MyazaInput';
8
+ import { emptyKeyPersonOwner, type KeyPersonOwnerEntry } from '../config/keyPeople';
9
+
10
+ const MAX_OWNERS = 10;
11
+
12
+ // Who owns this company.
13
+ //
14
+ // A beneficial owner is a natural person, so a corporate shareholder is a
15
+ // branch of the ownership chain that stops at a legal entity. Where the company
16
+ // is registered somewhere we can look up, the server follows it. Where it is
17
+ // not — a foreign parent, an offshore vehicle — this is the only route to the
18
+ // people above it, and asking is better than recording nothing.
19
+ //
20
+ // Deliberately short: a name and a share. Everything else about them is
21
+ // unknowable to the person filling in this form, and a longer list is one
22
+ // people abandon.
23
+
24
+ export function KeyPersonOwners({
25
+ owners,
26
+ onChange,
27
+ companyName,
28
+ }: {
29
+ owners: KeyPersonOwnerEntry[];
30
+ onChange: (owners: KeyPersonOwnerEntry[]) => void;
31
+ companyName: string;
32
+ }): React.ReactElement {
33
+ const { colors } = useTheme();
34
+ const patch = (index: number, next: Partial<KeyPersonOwnerEntry>) =>
35
+ onChange(owners.map((o, i) => (i === index ? { ...o, ...next } : o)));
36
+
37
+ return (
38
+ <View
39
+ style={{
40
+ borderWidth: 1,
41
+ borderColor: colors.border,
42
+ borderRadius: radius.lg,
43
+ padding: spacing.md,
44
+ }}
45
+ >
46
+ <MyazaText variant="bodySmall" style={{ fontWeight: '600' }}>
47
+ Who owns {companyName.trim() || 'this company'}? (optional)
48
+ </MyazaText>
49
+ <MyazaText variant="bodySmall" color={colors.textSecondary} style={{ marginTop: 2 }}>
50
+ A company cannot verify an identity, so tell us the people behind it if you know them.
51
+ </MyazaText>
52
+
53
+ {owners.map((owner, index) => (
54
+ <View key={index} style={{ marginTop: spacing.sm }}>
55
+ <MyazaInput
56
+ label={`Owner ${index + 1}`}
57
+ value={owner.name}
58
+ onChangeText={(name) => patch(index, { name })}
59
+ placeholder="Full name"
60
+ autoCapitalize="words"
61
+ />
62
+ <View style={{ height: spacing.xs }} />
63
+ <MyazaInput
64
+ label="Their share (optional)"
65
+ value={owner.ownershipPct}
66
+ onChangeText={(ownershipPct) => patch(index, { ownershipPct })}
67
+ placeholder="e.g. 75"
68
+ keyboardType="decimal-pad"
69
+ suffix={
70
+ <MyazaText variant="bodySmall" color={colors.textMuted}>
71
+ %
72
+ </MyazaText>
73
+ }
74
+ />
75
+ <Pressable
76
+ accessibilityRole="button"
77
+ accessibilityLabel={`Remove owner ${index + 1}`}
78
+ onPress={() => onChange(owners.filter((_, i) => i !== index))}
79
+ style={{ paddingVertical: spacing.xs, alignSelf: 'flex-start' }}
80
+ >
81
+ <MyazaText variant="bodySmall" color={colors.error}>
82
+ Remove
83
+ </MyazaText>
84
+ </Pressable>
85
+ </View>
86
+ ))}
87
+
88
+ {owners.length < MAX_OWNERS ? (
89
+ <Pressable
90
+ accessibilityRole="button"
91
+ onPress={() => onChange([...owners, emptyKeyPersonOwner()])}
92
+ style={{
93
+ marginTop: spacing.sm,
94
+ height: 40,
95
+ alignItems: 'center',
96
+ justifyContent: 'center',
97
+ borderRadius: radius.md,
98
+ borderWidth: 1,
99
+ borderColor: colors.border,
100
+ }}
101
+ >
102
+ <MyazaText variant="bodySmall" style={{ fontWeight: '600' }} color={colors.primary}>
103
+ {owners.length === 0 ? 'Add an owner' : 'Add another'}
104
+ </MyazaText>
105
+ </Pressable>
106
+ ) : null}
107
+ </View>
108
+ );
109
+ }
@@ -0,0 +1,68 @@
1
+ import React from 'react';
2
+ import { Pressable, View } from 'react-native';
3
+
4
+ import { MyazaText } from '../components/Typography';
5
+ import { useTheme } from '../components/runtime';
6
+ import { spacing } from '../config/theme';
7
+ import type { KeyPersonRole } from '../types/business';
8
+
9
+ // The representative form's role chips — real classifications, not job titles
10
+ // (those go in the free-text position field). Multi-select over the entry's
11
+ // role SET, with the last representative hat pinned on: unticking it would
12
+ // silently drop the person from the section they are being edited in.
13
+ // Mirrors the web SDK's KeyPersonRoleChips.
14
+
15
+ const REP_ROLES: Array<{ role: KeyPersonRole; label: string }> = [
16
+ { role: 'director', label: 'Director' },
17
+ { role: 'signatory', label: 'Signatory' },
18
+ ];
19
+
20
+ export function KeyPersonRoleChips({
21
+ roles,
22
+ onRoles,
23
+ }: {
24
+ roles: KeyPersonRole[];
25
+ onRoles: (next: KeyPersonRole[]) => void;
26
+ }): React.ReactElement {
27
+ const { colors } = useTheme();
28
+ const repCount = roles.filter((r) => r === 'director' || r === 'signatory').length;
29
+ return (
30
+ <View>
31
+ <MyazaText variant="label" style={{ marginBottom: spacing.sm }}>
32
+ Role
33
+ </MyazaText>
34
+ <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
35
+ {REP_ROLES.map(({ role, label }) => {
36
+ const active = roles.includes(role);
37
+ return (
38
+ <Pressable
39
+ key={role}
40
+ accessibilityRole="button"
41
+ accessibilityState={{ selected: active }}
42
+ onPress={() => {
43
+ if (active && repCount <= 1) return;
44
+ onRoles(active ? roles.filter((r) => r !== role) : [...roles, role]);
45
+ }}
46
+ style={{
47
+ borderRadius: 999,
48
+ borderWidth: 1,
49
+ borderColor: active ? colors.primary : colors.border,
50
+ backgroundColor: active ? colors.primary : 'transparent',
51
+ paddingHorizontal: 14,
52
+ paddingVertical: 7,
53
+ }}
54
+ >
55
+ <MyazaText
56
+ variant="bodyMedium"
57
+ color={active ? colors.onPrimary : colors.textSecondary}
58
+ style={{ fontWeight: '500' }}
59
+ >
60
+ {label}
61
+ </MyazaText>
62
+ </Pressable>
63
+ );
64
+ })}
65
+ </View>
66
+ </View>
67
+ );
68
+ }