@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.
Files changed (44) hide show
  1. package/README.md +43 -2
  2. package/package.json +1 -1
  3. package/src/components/Icon.tsx +3 -0
  4. package/src/components/KycSheet.tsx +209 -127
  5. package/src/components/MyazaInput.tsx +6 -1
  6. package/src/components/MyazaSelect.tsx +5 -0
  7. package/src/components/PoweredBy.tsx +20 -15
  8. package/src/components/ProgressBar.tsx +91 -0
  9. package/src/components/StepIndicator.tsx +145 -31
  10. package/src/components/fonts.ts +23 -0
  11. package/src/config/keyPeople.ts +10 -0
  12. package/src/config/questionnaire.ts +45 -4
  13. package/src/config/workflowMerge.ts +1 -0
  14. package/src/emrtd/crypto.ts +1 -1
  15. package/src/emrtd/dg1.ts +55 -0
  16. package/src/emrtd/ec-curves.ts +142 -0
  17. package/src/emrtd/ec.ts +196 -0
  18. package/src/emrtd/index.ts +1 -0
  19. package/src/emrtd/mrzKey.ts +21 -1
  20. package/src/emrtd/open.ts +169 -0
  21. package/src/emrtd/pace-params.ts +169 -0
  22. package/src/emrtd/pace.ts +295 -0
  23. package/src/emrtd/secureMessaging.ts +32 -14
  24. package/src/emrtd/session.ts +124 -20
  25. package/src/emrtd/suites.ts +99 -0
  26. package/src/index.ts +1 -0
  27. package/src/lib/step-log.ts +43 -0
  28. package/src/lib/step-window.ts +96 -0
  29. package/src/liveness/useLiveness.ts +1 -1
  30. package/src/screens/ApplicantRoleStep.tsx +1 -10
  31. package/src/screens/BusinessKeyPeopleStep.tsx +101 -45
  32. package/src/screens/IdTypeStep.tsx +15 -2
  33. package/src/screens/KeyPersonCard.tsx +119 -0
  34. package/src/screens/{BusinessKeyPersonRow.tsx → KeyPersonForm.tsx} +35 -57
  35. package/src/screens/KeyPersonSheet.tsx +184 -0
  36. package/src/screens/NfcStep.tsx +12 -0
  37. package/src/screens/QuestionnaireField.tsx +30 -0
  38. package/src/screens/QuestionnaireStep.tsx +3 -1
  39. package/src/screens/nfc/NfcSuccessPanel.tsx +13 -6
  40. package/src/services/deviceMetadata.ts +9 -1
  41. package/src/store/derive.ts +7 -0
  42. package/src/store/kycStore.ts +25 -1
  43. package/src/types/config.ts +17 -0
  44. 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: 'Verification complete',
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 { 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
  }
@@ -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' && available.length === 0) {
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
- {available.map((t) => {
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
+ }