@myazahq/kyc-sdk-react-native 2.1.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -2
- package/package.json +1 -1
- package/src/components/Icon.tsx +3 -0
- package/src/components/KycSheet.tsx +209 -127
- package/src/components/MyazaInput.tsx +6 -1
- package/src/components/MyazaSelect.tsx +5 -0
- package/src/components/PoweredBy.tsx +20 -15
- package/src/components/ProgressBar.tsx +91 -0
- package/src/components/StepIndicator.tsx +145 -31
- package/src/components/fonts.ts +23 -0
- package/src/config/keyPeople.ts +10 -0
- package/src/config/questionnaire.ts +45 -4
- package/src/config/workflowMerge.ts +1 -0
- package/src/emrtd/crypto.ts +1 -1
- package/src/emrtd/dg1.ts +55 -0
- package/src/emrtd/ec-curves.ts +142 -0
- package/src/emrtd/ec.ts +196 -0
- package/src/emrtd/index.ts +1 -0
- package/src/emrtd/mrzKey.ts +21 -1
- package/src/emrtd/open.ts +169 -0
- package/src/emrtd/pace-params.ts +169 -0
- package/src/emrtd/pace.ts +295 -0
- package/src/emrtd/secureMessaging.ts +32 -14
- package/src/emrtd/session.ts +124 -20
- package/src/emrtd/suites.ts +99 -0
- package/src/index.ts +1 -0
- package/src/lib/step-log.ts +43 -0
- package/src/lib/step-window.ts +96 -0
- package/src/liveness/useLiveness.ts +1 -1
- package/src/screens/ApplicantRoleStep.tsx +1 -10
- package/src/screens/BusinessKeyPeopleStep.tsx +101 -45
- package/src/screens/IdTypeStep.tsx +15 -2
- package/src/screens/KeyPersonCard.tsx +119 -0
- package/src/screens/{BusinessKeyPersonRow.tsx → KeyPersonForm.tsx} +35 -57
- package/src/screens/KeyPersonSheet.tsx +184 -0
- package/src/screens/NfcStep.tsx +12 -0
- package/src/screens/QuestionnaireField.tsx +30 -0
- package/src/screens/QuestionnaireStep.tsx +3 -1
- package/src/screens/nfc/NfcSuccessPanel.tsx +13 -6
- package/src/services/deviceMetadata.ts +9 -1
- package/src/store/derive.ts +7 -0
- package/src/store/kycStore.ts +25 -1
- package/src/types/config.ts +17 -0
- package/src/types/workflow.ts +10 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Session step log — records each SDK step the user reaches, with a
|
|
2
|
+
// timestamp, so the server can reconstruct the journey on the verification
|
|
3
|
+
// timeline ("consent opened → ID type chosen → document captured → …").
|
|
4
|
+
// Rides the verify submission as metadata.device.stepLog — the same free-form
|
|
5
|
+
// channel the Device Intelligence fingerprint uses: no extra network calls,
|
|
6
|
+
// no new endpoint, and old SDKs simply never send it. `sentAt` is stamped at
|
|
7
|
+
// collect time so the server can correct client-clock skew against its own
|
|
8
|
+
// receipt time. Step names only — never PII. Mirrors the web SDK's
|
|
9
|
+
// lib/step-log.ts; keep the two in lockstep.
|
|
10
|
+
|
|
11
|
+
export interface StepLogEntry {
|
|
12
|
+
step: string;
|
|
13
|
+
at: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface StepLog {
|
|
17
|
+
steps: StepLogEntry[];
|
|
18
|
+
sentAt: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const MAX_ENTRIES = 40;
|
|
22
|
+
|
|
23
|
+
let entries: StepLogEntry[] = [];
|
|
24
|
+
|
|
25
|
+
/** Fresh slate per session (called from the store's reset — each modal open). */
|
|
26
|
+
export function resetStepLog(): void {
|
|
27
|
+
entries = [];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Records a step visit. Consecutive duplicates are collapsed; back-and-forth
|
|
31
|
+
* navigation is kept — repeat visits are honest journey data. */
|
|
32
|
+
export function recordStep(step: string): void {
|
|
33
|
+
if (entries.length >= MAX_ENTRIES) return;
|
|
34
|
+
if (entries[entries.length - 1]?.step === step) return;
|
|
35
|
+
entries.push({ step, at: new Date().toISOString() });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Snapshot attached to the verify submission. Null when nothing was recorded
|
|
39
|
+
* so the field is simply absent. */
|
|
40
|
+
export function getStepLog(): StepLog | null {
|
|
41
|
+
if (entries.length === 0) return null;
|
|
42
|
+
return { steps: [...entries], sentAt: new Date().toISOString() };
|
|
43
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which step circles to draw, and when to start collapsing them.
|
|
3
|
+
*
|
|
4
|
+
* A KYB flow can reach FOURTEEN steps (consent → email → phone →
|
|
5
|
+
* business-details → key-people → documents → applicant-role → country-select →
|
|
6
|
+
* id-type → document-capture → nfc → liveness → questionnaire → submitted), and
|
|
7
|
+
* an individual flow eleven. The circles are a fixed size, so they cannot shrink
|
|
8
|
+
* to fit: on a 360dp Android with 16dp padding, ten steps leave 14dp TOTAL for
|
|
9
|
+
* nine connectors, and twelve steps overflow the row outright.
|
|
10
|
+
*
|
|
11
|
+
* COLLAPSING IS A LAST RESORT. The row fits as many real circles as the measured
|
|
12
|
+
* width allows and only windows once they would stop looking like a connected
|
|
13
|
+
* chain — which is what the minimum connector length below decides. A fixed cap
|
|
14
|
+
* would collapse a 9-step flow on a large phone that had room for all of it.
|
|
15
|
+
*
|
|
16
|
+
* When it does window, two rules keep the elision honest:
|
|
17
|
+
*
|
|
18
|
+
* • The LAST step is always shown. Without it the ellipsis hides how much is
|
|
19
|
+
* left, which is the one thing a progress indicator exists to answer.
|
|
20
|
+
* • The FIRST step is always shown, so the row still reads as a whole journey
|
|
21
|
+
* rather than a fragment floating in the middle.
|
|
22
|
+
*
|
|
23
|
+
* 1 ··· 5 6 7 ··· 10
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** A rendered slot: a step index, or a collapsed run of them. */
|
|
27
|
+
export type StepSlot = number | 'ellipsis';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Shortest connector that still reads as a line joining two circles rather than
|
|
31
|
+
* a stray dash. Below this the "chain" metaphor is gone and the row just looks
|
|
32
|
+
* cramped — which is the state the 10-step screenshot was already in.
|
|
33
|
+
*/
|
|
34
|
+
export const MIN_CONNECTOR = 10;
|
|
35
|
+
|
|
36
|
+
/** Horizontal margin a connector carries on each side (matches the component). */
|
|
37
|
+
const CONNECTOR_MARGIN = 6;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Never collapse below this many circles. At 5 the pattern still reads as
|
|
41
|
+
* first · gap · current · gap · last; below it the row says nothing useful.
|
|
42
|
+
*/
|
|
43
|
+
const MIN_CIRCLES = 5;
|
|
44
|
+
|
|
45
|
+
const clamp = (v: number, lo: number, hi: number): number => Math.min(Math.max(v, lo), hi);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* How many circles fit across `width` before they stop looking like a chain.
|
|
49
|
+
*
|
|
50
|
+
* n·size + (n−1)·(margins + minConnector) ≤ width
|
|
51
|
+
*
|
|
52
|
+
* Returns 0 when the width is not known yet, which the caller reads as "render
|
|
53
|
+
* them all" — an un-measured first frame should not flash a collapsed row.
|
|
54
|
+
*/
|
|
55
|
+
export function fitStepCircles(width: number, circleSize: number): number {
|
|
56
|
+
if (!Number.isFinite(width) || width <= 0) return 0;
|
|
57
|
+
const gap = CONNECTOR_MARGIN + MIN_CONNECTOR;
|
|
58
|
+
return Math.max(1, Math.floor((width + gap) / (circleSize + gap)));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param total how many steps the flow has
|
|
63
|
+
* @param active 0-based index of the current step
|
|
64
|
+
* @param maxCircles how many circles fit (from `fitStepCircles`); 0 = unknown,
|
|
65
|
+
* render everything
|
|
66
|
+
*/
|
|
67
|
+
export function windowedSteps(total: number, active: number, maxCircles = 0): StepSlot[] {
|
|
68
|
+
if (total <= 0) return [];
|
|
69
|
+
const all = Array.from({ length: total }, (_, i) => i);
|
|
70
|
+
if (maxCircles <= 0 || total <= maxCircles) return all;
|
|
71
|
+
|
|
72
|
+
// A collapsed row also pays for up to TWO ellipses, each about as wide as a
|
|
73
|
+
// circle once its padding and connectors are counted. Reserving only one
|
|
74
|
+
// between them under-counted, and the row overflowed — which is invisible
|
|
75
|
+
// until you notice every `flex: 1` connector has collapsed to zero width.
|
|
76
|
+
const budget = Math.max(MIN_CIRCLES, maxCircles - 2);
|
|
77
|
+
if (total <= budget) return all;
|
|
78
|
+
|
|
79
|
+
const first = 0;
|
|
80
|
+
const last = total - 1;
|
|
81
|
+
const current = clamp(active, first, last);
|
|
82
|
+
|
|
83
|
+
// First and last are drawn unconditionally; the rest of the budget is a
|
|
84
|
+
// window centred on the current step.
|
|
85
|
+
const windowSize = Math.max(1, budget - 2);
|
|
86
|
+
const half = Math.floor((windowSize - 1) / 2);
|
|
87
|
+
const start = clamp(current - half, first + 1, Math.max(first + 1, last - windowSize));
|
|
88
|
+
const end = Math.min(start + windowSize - 1, last - 1);
|
|
89
|
+
|
|
90
|
+
const slots: StepSlot[] = [first];
|
|
91
|
+
if (start > first + 1) slots.push('ellipsis');
|
|
92
|
+
for (let i = start; i <= end; i += 1) slots.push(i);
|
|
93
|
+
if (end < last - 1) slots.push('ellipsis');
|
|
94
|
+
slots.push(last);
|
|
95
|
+
return slots;
|
|
96
|
+
}
|
|
@@ -562,7 +562,7 @@ export function useLiveness(opts: UseLivenessOptions = {}): UseLivenessReturn {
|
|
|
562
562
|
setState((s) => ({
|
|
563
563
|
...s,
|
|
564
564
|
phase: 'complete',
|
|
565
|
-
instruction: '
|
|
565
|
+
instruction: 'Capture complete',
|
|
566
566
|
activeChallenge: null,
|
|
567
567
|
positionGuidance: null,
|
|
568
568
|
}));
|
|
@@ -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
|
}
|
|
@@ -99,6 +99,19 @@ export function IdTypeStep(): React.ReactElement {
|
|
|
99
99
|
return (table[country.toUpperCase()] ?? []).filter((t) => allowed(t.key));
|
|
100
100
|
}, [country, config.idTypes, config.countries, serverConfig]);
|
|
101
101
|
|
|
102
|
+
// Document Intelligence off ⇒ number-only IDs only (there is no document
|
|
103
|
+
// capture step), so drop every document-scanned ID from the picker. This is
|
|
104
|
+
// what makes the disabled step actually disappear from the live flow rather
|
|
105
|
+
// than still offering passports and licences that then have nowhere to be
|
|
106
|
+
// captured. Mirrors the web SDK's IdTypeStep.
|
|
107
|
+
const visible = useMemo<IdTypeDefinition[]>(
|
|
108
|
+
() =>
|
|
109
|
+
config.enableDocumentCapture === false
|
|
110
|
+
? available.filter((t) => !t.requiresDocumentCapture)
|
|
111
|
+
: available,
|
|
112
|
+
[available, config.enableDocumentCapture],
|
|
113
|
+
);
|
|
114
|
+
|
|
102
115
|
if (serverConfig.status === 'loading') {
|
|
103
116
|
return (
|
|
104
117
|
<View style={{ paddingVertical: spacing.xl, alignItems: 'center' }}>
|
|
@@ -107,7 +120,7 @@ export function IdTypeStep(): React.ReactElement {
|
|
|
107
120
|
);
|
|
108
121
|
}
|
|
109
122
|
|
|
110
|
-
if (serverConfig.status === 'ready' &&
|
|
123
|
+
if (serverConfig.status === 'ready' && visible.length === 0) {
|
|
111
124
|
return (
|
|
112
125
|
<View
|
|
113
126
|
style={{
|
|
@@ -127,7 +140,7 @@ export function IdTypeStep(): React.ReactElement {
|
|
|
127
140
|
|
|
128
141
|
return (
|
|
129
142
|
<View>
|
|
130
|
-
{
|
|
143
|
+
{visible.map((t) => {
|
|
131
144
|
const selected = selectedIdType === t.key;
|
|
132
145
|
return (
|
|
133
146
|
<Pressable
|
|
@@ -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
|
+
}
|