@myazahq/kyc-sdk-react-native 2.4.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 (117) 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 -1
  16. package/src/MyazaKYC.tsx +12 -0
  17. package/src/components/DashedBorder.tsx +55 -0
  18. package/src/components/DialCodePicker.tsx +14 -7
  19. package/src/components/Icon.tsx +9 -0
  20. package/src/components/MultiIdProgress.tsx +107 -0
  21. package/src/components/MyazaInput.tsx +23 -4
  22. package/src/components/MyazaSelect.tsx +2 -1
  23. package/src/components/OwnershipSlider.tsx +102 -0
  24. package/src/components/PhoneNumberInput.tsx +37 -4
  25. package/src/components/StaggerIn.tsx +63 -0
  26. package/src/components/StepView.tsx +26 -0
  27. package/src/components/glass/FloatingSheet.tsx +73 -21
  28. package/src/components/glass/GlassSheet.tsx +14 -2
  29. package/src/components/stepHeaderMeta.tsx +3 -1
  30. package/src/config/business.ts +6 -6
  31. package/src/config/businessDetailsValidity.ts +52 -0
  32. package/src/config/businessPrefill.ts +72 -0
  33. package/src/config/businessSteps.ts +14 -3
  34. package/src/config/keyPeople.ts +111 -7
  35. package/src/config/keyPeopleCorporate.ts +19 -0
  36. package/src/config/keyPeopleOwnerTypes.ts +12 -0
  37. package/src/config/keyPeopleOwners.ts +26 -0
  38. package/src/config/keyPeoplePrefill.ts +138 -0
  39. package/src/config/keyPeopleSectionDefs.ts +65 -0
  40. package/src/config/keyPeopleSections.ts +167 -0
  41. package/src/config/registrationHint.ts +1 -1
  42. package/src/config/stepOrder.ts +24 -4
  43. package/src/config/theme.ts +8 -2
  44. package/src/config/website.ts +36 -0
  45. package/src/config/workflowMerge.ts +5 -0
  46. package/src/emrtd/activeAuth.ts +100 -0
  47. package/src/emrtd/extras.ts +76 -0
  48. package/src/emrtd/files.ts +5 -0
  49. package/src/emrtd/index.ts +1 -0
  50. package/src/emrtd/read.ts +8 -1
  51. package/src/emrtd/session.ts +41 -0
  52. package/src/emrtd/stages.ts +11 -0
  53. package/src/lib/contact-recovery.ts +44 -0
  54. package/src/lib/multi-id.ts +210 -0
  55. package/src/lib/resubmit.ts +130 -0
  56. package/src/lib/screen-corners.ts +50 -0
  57. package/src/lib/step-log.ts +24 -3
  58. package/src/lib/use-multi-id-plan.ts +29 -0
  59. package/src/liveness/useModelReady.ts +65 -0
  60. package/src/liveness/visionCameraFaceDetector.ts +35 -0
  61. package/src/screens/ApplicantRoleStep.tsx +9 -5
  62. package/src/screens/BusinessCheckPanel.tsx +89 -0
  63. package/src/screens/BusinessDetailsFields.tsx +109 -0
  64. package/src/screens/BusinessDetailsStep.tsx +131 -145
  65. package/src/screens/BusinessDocumentSlot.tsx +2 -3
  66. package/src/screens/BusinessDocumentsStep.tsx +1 -1
  67. package/src/screens/BusinessKeyPeopleStep.tsx +103 -71
  68. package/src/screens/BusinessPickedCard.tsx +86 -0
  69. package/src/screens/BusinessPickedSection.tsx +51 -0
  70. package/src/screens/BusinessRegistryPickers.tsx +67 -0
  71. package/src/screens/BusinessSandboxToggle.tsx +114 -0
  72. package/src/screens/BusinessSearch.tsx +199 -0
  73. package/src/screens/BusinessSearchResults.tsx +115 -0
  74. package/src/screens/CompanyInfoFields.tsx +86 -51
  75. package/src/screens/ConsentStep.tsx +31 -0
  76. package/src/screens/ContactDestinationField.tsx +1 -0
  77. package/src/screens/ContactVerificationStep.tsx +34 -3
  78. package/src/screens/IdTypeStep.tsx +12 -5
  79. package/src/screens/KeepLinksSheet.tsx +88 -0
  80. package/src/screens/KeyPeopleAwaitCard.tsx +204 -0
  81. package/src/screens/KeyPeopleAwaitList.tsx +126 -0
  82. package/src/screens/KeyPeoplePending.tsx +154 -0
  83. package/src/screens/KeyPeopleSection.tsx +162 -0
  84. package/src/screens/KeyPeopleSectionsList.tsx +103 -0
  85. package/src/screens/KeyPeopleUboExemption.tsx +72 -0
  86. package/src/screens/KeyPersonCard.tsx +36 -4
  87. package/src/screens/KeyPersonForm.tsx +195 -36
  88. package/src/screens/KeyPersonKindToggle.tsx +68 -0
  89. package/src/screens/KeyPersonOwners.tsx +109 -0
  90. package/src/screens/KeyPersonRoleChips.tsx +68 -0
  91. package/src/screens/KeyPersonSheet.tsx +92 -108
  92. package/src/screens/LivenessStep.tsx +18 -0
  93. package/src/screens/NfcStep.tsx +14 -0
  94. package/src/screens/ProofOfAddressStep.tsx +6 -3
  95. package/src/screens/SubmittedStep.tsx +66 -10
  96. package/src/screens/nfc/NfcReadProgress.tsx +1 -0
  97. package/src/screens/useAwaitingPeople.ts +90 -0
  98. package/src/services/api-types.ts +158 -2
  99. package/src/services/api-verify-types.ts +53 -1
  100. package/src/services/api.ts +104 -0
  101. package/src/services/deviceMetadata.ts +1 -1
  102. package/src/services/errors.ts +10 -2
  103. package/src/services/workflowGate.ts +1 -0
  104. package/src/specs/MyazaFaceDetector.nitro.ts +29 -0
  105. package/src/store/businessCheck.ts +113 -0
  106. package/src/store/derive.ts +15 -0
  107. package/src/store/kycStore.ts +241 -14
  108. package/src/store/serverConfig.ts +2 -0
  109. package/src/store/session.ts +208 -0
  110. package/src/store/state.ts +155 -2
  111. package/src/store/submit.ts +63 -13
  112. package/src/store/submitApplicant.ts +5 -11
  113. package/src/types/business.ts +23 -2
  114. package/src/types/config.ts +22 -1
  115. package/src/types/verification.ts +1 -1
  116. package/src/types/workflow.ts +15 -0
  117. package/src/screens/KeyPeopleInviteLinks.tsx +0 -239
@@ -0,0 +1,88 @@
1
+ import React, { useState } from 'react';
2
+ import { Linking, View } from 'react-native';
3
+ import * as Clipboard from 'expo-clipboard';
4
+
5
+ import { spacing } from '../config/theme';
6
+ import { FloatingSheet } from '../components/glass/FloatingSheet';
7
+ import { Icon } from '../components/Icon';
8
+ import { MyazaAlert } from '../components/MyazaAlert';
9
+ import { MyazaButton } from '../components/MyazaButton';
10
+ import { MyazaText } from '../components/Typography';
11
+ import { useTheme } from '../components/runtime';
12
+
13
+ // Shown when the applicant taps Done while key people still owe a check.
14
+ //
15
+ // The invite links live on THIS success screen, and on mobile the screen dies
16
+ // with the app: once closed, the applicant has no way back to the links unless
17
+ // the org re-sends them. The session's own hosted web page is the way back —
18
+ // the same rehydrated success screen, alive for as long as the invites are —
19
+ // so the one moment to hand it over is the moment they leave.
20
+
21
+ export function KeepLinksSheet({
22
+ open,
23
+ url,
24
+ onClose,
25
+ onDone,
26
+ }: {
27
+ open: boolean;
28
+ url: string;
29
+ /** Dismiss the sheet only (they are staying on the success screen). */
30
+ onClose: () => void;
31
+ /** Close the sheet AND the flow (they are done here). */
32
+ onDone: () => void;
33
+ }): React.ReactElement {
34
+ const { colors } = useTheme();
35
+ const [copied, setCopied] = useState(false);
36
+
37
+ return (
38
+ <FloatingSheet visible={open} onClose={onClose}>
39
+ <View style={{ padding: spacing.md, paddingTop: spacing.xs, alignItems: 'center' }}>
40
+ <View
41
+ style={{
42
+ width: 52,
43
+ height: 52,
44
+ borderRadius: 26,
45
+ backgroundColor: colors.primary50,
46
+ alignItems: 'center',
47
+ justifyContent: 'center',
48
+ }}
49
+ >
50
+ <Icon name="globe" size={26} color={colors.primary} />
51
+ </View>
52
+
53
+ <View style={{ height: spacing.md }} />
54
+ <MyazaText variant="heading3" style={{ textAlign: 'center' }}>
55
+ Keep a way back to these links
56
+ </MyazaText>
57
+ <View style={{ height: spacing.md }} />
58
+ <View style={{ width: '100%' }}>
59
+ <MyazaAlert
60
+ variant="warning"
61
+ title="This screen will not be shown again"
62
+ message="Your key people's links live on a web page made for you. Open it in your browser or copy the address somewhere safe, then check who has finished and share the links from there at any time."
63
+ />
64
+ </View>
65
+
66
+ <View style={{ height: spacing.lg }} />
67
+ <View style={{ width: '100%', gap: spacing.sm }}>
68
+ <MyazaButton
69
+ label="Open in my browser"
70
+ leadingIcon="globe"
71
+ onPress={() => void Linking.openURL(url).catch(() => undefined)}
72
+ />
73
+ <MyazaButton
74
+ label={copied ? 'Link copied' : 'Copy the page link'}
75
+ variant="outline"
76
+ leadingIcon={copied ? 'check' : 'copy'}
77
+ onPress={() => {
78
+ void Clipboard.setStringAsync(url)
79
+ .then(() => setCopied(true))
80
+ .catch(() => undefined);
81
+ }}
82
+ />
83
+ <MyazaButton label="I'm done here" variant="ghost" onPress={onDone} />
84
+ </View>
85
+ </View>
86
+ </FloatingSheet>
87
+ );
88
+ }
@@ -0,0 +1,204 @@
1
+ import React, { useState } from 'react';
2
+ import { Pressable, Share, View } from 'react-native';
3
+ import * as Clipboard from 'expo-clipboard';
4
+
5
+ import { radius, spacing } from '../config/theme';
6
+ import { useTheme } from '../components/runtime';
7
+ import { MyazaText } from '../components/Typography';
8
+ import { Icon } from '../components/Icon';
9
+ import { CountryFlag } from '../components/CountryFlag';
10
+ import { regionCountryName } from '../config/regions';
11
+ import { KEY_PERSON_ROLE_LABELS } from '../config/keyPeople';
12
+ import type { AwaitRow } from './KeyPeopleAwaitList';
13
+
14
+ // One person's card on the KYB "awaiting users" list — split from
15
+ // KeyPeopleAwaitList per the 200-line rule.
16
+
17
+ const ROLE_LABELS: Record<string, string> = {
18
+ ...KEY_PERSON_ROLE_LABELS,
19
+ authorized_representative: 'Authorized representative',
20
+ };
21
+
22
+ function statusView(
23
+ row: AwaitRow,
24
+ colors: ReturnType<typeof useTheme>['colors'],
25
+ ): { label: string; bg: string; fg: string } {
26
+ switch (row.status) {
27
+ case 'verified':
28
+ return { label: 'VERIFIED', bg: colors.successBg, fg: colors.success };
29
+ case 'submitted':
30
+ return { label: 'SUBMITTED', bg: colors.successBg, fg: colors.success };
31
+ case 'failed':
32
+ return { label: 'CHECK FAILED', bg: colors.errorBg, fg: colors.error };
33
+ case 'not_needed':
34
+ return { label: 'NOT NEEDED', bg: colors.border, fg: colors.textMuted };
35
+ default:
36
+ return {
37
+ label: row.isCorporate ? 'KYB PENDING' : 'KYC PENDING',
38
+ bg: colors.primary100,
39
+ fg: colors.primary,
40
+ };
41
+ }
42
+ }
43
+
44
+ export function AwaitCard({ row }: { row: AwaitRow }): React.ReactElement {
45
+ const { colors } = useTheme();
46
+ const [copied, setCopied] = useState(false);
47
+ const status = statusView(row, colors);
48
+
49
+ const share = (): void => {
50
+ if (!row.inviteUrl) return;
51
+ Share.share({ message: row.inviteUrl }).catch(() => undefined);
52
+ };
53
+ const copy = (): void => {
54
+ if (!row.inviteUrl) return;
55
+ Clipboard.setStringAsync(row.inviteUrl)
56
+ .then(() => {
57
+ setCopied(true);
58
+ setTimeout(() => setCopied(false), 2000);
59
+ })
60
+ .catch(() => undefined);
61
+ };
62
+
63
+ return (
64
+ <View
65
+ style={{
66
+ backgroundColor: colors.backgroundSecondary,
67
+ borderRadius: radius.md,
68
+ padding: spacing.md,
69
+ marginBottom: spacing.sm,
70
+ }}
71
+ >
72
+ <View style={{ flexDirection: 'row', alignItems: 'flex-start' }}>
73
+ <View style={{ flex: 1, minWidth: 0, marginRight: spacing.sm }}>
74
+ <View style={{ flexDirection: 'row', alignItems: 'center' }}>
75
+ <MyazaText
76
+ variant="bodyMedium"
77
+ numberOfLines={1}
78
+ style={{ fontWeight: '700', flexShrink: 1 }}
79
+ >
80
+ {row.name}
81
+ {row.isApplicant ? (
82
+ <MyazaText variant="bodySmall" color={colors.textMuted}>
83
+ {' (you)'}
84
+ </MyazaText>
85
+ ) : null}
86
+ </MyazaText>
87
+ {row.isCorporate ? (
88
+ // Readability over subtlety: foreground text on a bordered chip.
89
+ // The muted-on-muted version disappeared on dark org themes.
90
+ <View
91
+ style={{
92
+ flexDirection: 'row',
93
+ alignItems: 'center',
94
+ borderWidth: 1,
95
+ borderColor: colors.border,
96
+ backgroundColor: colors.background,
97
+ borderRadius: radius.full,
98
+ paddingHorizontal: spacing.xs + 4,
99
+ paddingVertical: 2,
100
+ marginLeft: spacing.xs + 2,
101
+ }}
102
+ >
103
+ <Icon name="building-2" size={12} color={colors.textDark} />
104
+ <MyazaText
105
+ variant="bodySmall"
106
+ color={colors.textDark}
107
+ style={{ fontSize: 12, fontWeight: '600', marginLeft: 4 }}
108
+ >
109
+ Company
110
+ </MyazaText>
111
+ </View>
112
+ ) : null}
113
+ </View>
114
+ <View
115
+ style={{ flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', marginTop: 2 }}
116
+ >
117
+ <MyazaText variant="bodySmall" color={colors.textMuted}>
118
+ {ROLE_LABELS[row.role] ?? 'Key person'}
119
+ {row.pct ? ` · ${row.pct}%` : ''}
120
+ {row.country ? ' · ' : ''}
121
+ </MyazaText>
122
+ {row.country ? (
123
+ <>
124
+ <CountryFlag country={row.country} size={14} />
125
+ <MyazaText variant="bodySmall" color={colors.textMuted}>
126
+ {` ${regionCountryName(row.country)}`}
127
+ </MyazaText>
128
+ </>
129
+ ) : null}
130
+ </View>
131
+ </View>
132
+ <View
133
+ style={{
134
+ borderRadius: radius.full,
135
+ paddingHorizontal: spacing.sm + 4,
136
+ paddingVertical: spacing.xs,
137
+ backgroundColor: status.bg,
138
+ }}
139
+ >
140
+ <MyazaText
141
+ variant="bodySmall"
142
+ color={status.fg}
143
+ style={{ fontWeight: '700', fontSize: 11, letterSpacing: 0.6 }}
144
+ >
145
+ {status.label}
146
+ </MyazaText>
147
+ </View>
148
+ </View>
149
+
150
+ {/* A failed check keeps its link — that is the retry. */}
151
+ {row.inviteUrl ? (
152
+ <View style={{ flexDirection: 'row', marginTop: spacing.sm + 4 }}>
153
+ <Pressable
154
+ onPress={copy}
155
+ accessibilityRole="button"
156
+ accessibilityLabel={`Copy ${row.name}'s verification link`}
157
+ style={{
158
+ flex: 1,
159
+ flexDirection: 'row',
160
+ alignItems: 'center',
161
+ justifyContent: 'center',
162
+ backgroundColor: colors.primary100,
163
+ borderRadius: radius.full,
164
+ paddingVertical: spacing.sm + 2,
165
+ marginRight: spacing.sm,
166
+ }}
167
+ >
168
+ <Icon name={copied ? 'check' : 'copy'} size={15} color={colors.primary} />
169
+ <MyazaText
170
+ variant="bodySmall"
171
+ color={colors.primary}
172
+ style={{ fontWeight: '600', marginLeft: 6 }}
173
+ >
174
+ {copied ? 'Copied' : 'Copy link'}
175
+ </MyazaText>
176
+ </Pressable>
177
+ <Pressable
178
+ onPress={share}
179
+ accessibilityRole="button"
180
+ accessibilityLabel={`Share ${row.name}'s verification link`}
181
+ style={{
182
+ flex: 1,
183
+ flexDirection: 'row',
184
+ alignItems: 'center',
185
+ justifyContent: 'center',
186
+ backgroundColor: colors.primary100,
187
+ borderRadius: radius.full,
188
+ paddingVertical: spacing.sm + 2,
189
+ }}
190
+ >
191
+ <Icon name="share" size={15} color={colors.primary} />
192
+ <MyazaText
193
+ variant="bodySmall"
194
+ color={colors.primary}
195
+ style={{ fontWeight: '600', marginLeft: 6 }}
196
+ >
197
+ Share
198
+ </MyazaText>
199
+ </Pressable>
200
+ </View>
201
+ ) : null}
202
+ </View>
203
+ );
204
+ }
@@ -0,0 +1,126 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+
4
+ import { spacing } from '../config/theme';
5
+ import { useTheme } from '../components/runtime';
6
+ import { MyazaText } from '../components/Typography';
7
+ import { AwaitCard } from './KeyPeopleAwaitCard';
8
+ import { StaggerIn } from '../components/StaggerIn';
9
+ import type { AwaitingPersonPayload } from '../services/api-types';
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // "Awaiting users" — the KYB success screen's people section, rendered from
13
+ // the SERVER's reconciled list (mirrors the web SDK's KeyPeopleAwaitList).
14
+ // Registry discovery can add people the applicant never listed, so the rows
15
+ // here come from the session summary once it settles — never from what was
16
+ // typed. Statuses stay live: the people named go and verify after this screen
17
+ // is first shown.
18
+ // ---------------------------------------------------------------------------
19
+
20
+ export interface AwaitRow {
21
+ name: string;
22
+ role: string;
23
+ pct: string | null;
24
+ country: string | null;
25
+ status: AwaitingPersonPayload['status'];
26
+ inviteUrl?: string;
27
+ isApplicant?: boolean;
28
+ /** A company on the list completes a KYB application, not a KYC. It gets a
29
+ * Company tag and its pending pill reads "KYB PENDING". */
30
+ isCorporate?: boolean;
31
+ }
32
+
33
+ /** Ownership as the list renders it: a whole number where that is honest. */
34
+ function formatPct(pct: number | null): string | null {
35
+ if (pct === null || !Number.isFinite(pct)) return null;
36
+ return Number.isInteger(pct) ? String(pct) : pct.toFixed(2).replace(/\.?0+$/, '');
37
+ }
38
+
39
+ /** Server payload → display rows. */
40
+ export function rowsFromServer(people: AwaitingPersonPayload[]): AwaitRow[] {
41
+ return people.map((p) => ({
42
+ name: p.name,
43
+ role: p.role,
44
+ pct: formatPct(p.ownershipPct),
45
+ country: p.country,
46
+ status: p.status,
47
+ ...(p.inviteUrl ? { inviteUrl: p.inviteUrl } : {}),
48
+ isApplicant: p.isApplicant,
49
+ isCorporate: p.isCorporate ?? false,
50
+ }));
51
+ }
52
+
53
+ const SECTIONS: Array<{ role: string; label: string }> = [
54
+ { role: 'beneficial_owner', label: 'UBOS' },
55
+ { role: 'director', label: 'DIRECTORS' },
56
+ { role: 'signatory', label: 'SIGNATORIES' },
57
+ { role: 'shareholder', label: 'SHAREHOLDERS' },
58
+ { role: 'authorized_representative', label: 'REPRESENTATIVES' },
59
+ ];
60
+ const KNOWN_ROLES = new Set(SECTIONS.map((s) => s.role));
61
+
62
+ export function KeyPeopleAwaitList({ rows }: { rows: AwaitRow[] }): React.ReactElement | null {
63
+ const { colors } = useTheme();
64
+ if (rows.length === 0) return null;
65
+
66
+ // Anything whose role we have no section for still has to appear — quietly
67
+ // dropping people from a "who still owes a check" list is the worst failure
68
+ // it could have.
69
+ const groups = SECTIONS.map(({ role, label }) => ({
70
+ label,
71
+ rows: rows.filter((r) => r.role === role),
72
+ })).filter((g) => g.rows.length > 0);
73
+ const others = rows.filter((r) => !KNOWN_ROLES.has(r.role));
74
+ if (others.length > 0) groups.push({ label: 'OTHER PEOPLE', rows: others });
75
+
76
+ const outstanding = rows.filter((r) => r.status === 'pending' || r.status === 'failed').length;
77
+
78
+ // Entrance stagger, mirroring the web SDK: cards rise in reading order,
79
+ // ~45ms apart, capped so a long roster is not a slow reveal. Together with
80
+ // the skeleton this list replaces (drawn at the same geometry), the
81
+ // hand-off reads as the ghost roster resolving into the real one.
82
+ let entranceIndex = 0;
83
+
84
+ return (
85
+ <View style={{ width: '100%', marginTop: spacing.lg }}>
86
+ <MyazaText
87
+ variant="bodySmall"
88
+ color={colors.textMuted}
89
+ style={{ textAlign: 'center', marginBottom: spacing.md }}
90
+ >
91
+ {outstanding === 0
92
+ ? 'Everyone on this application has completed their identity check.'
93
+ : 'To complete the review, the people below must verify their identity with a KYC check. Anyone with an email on file has already been sent their link.'}
94
+ </MyazaText>
95
+
96
+ {groups.map((group) => (
97
+ <View key={group.label} style={{ marginBottom: spacing.sm }}>
98
+ <MyazaText
99
+ variant="bodySmall"
100
+ color={colors.textMuted}
101
+ style={{ fontWeight: '600', letterSpacing: 1.2, marginBottom: spacing.xs + 2 }}
102
+ >
103
+ {group.label}
104
+ </MyazaText>
105
+ {group.rows.map((row, i) => (
106
+ <StaggerIn key={`${group.label}-${i}`} delayMs={Math.min(entranceIndex++, 8) * 45}>
107
+ <AwaitCard row={row} />
108
+ </StaggerIn>
109
+ ))}
110
+ </View>
111
+ ))}
112
+
113
+ {outstanding > 0 ? (
114
+ <MyazaText
115
+ variant="bodySmall"
116
+ color={colors.textMuted}
117
+ // Clear air below: this caption is the list's last element and the
118
+ // Done button renders immediately after it in the parent.
119
+ style={{ textAlign: 'center', marginTop: spacing.xs, marginBottom: spacing.lg }}
120
+ >
121
+ Links are valid for 14 days.
122
+ </MyazaText>
123
+ ) : null}
124
+ </View>
125
+ );
126
+ }
@@ -0,0 +1,154 @@
1
+ import React, { useEffect, useRef } from 'react';
2
+ import { ActivityIndicator, Animated, 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 { useReduceMotion } from '../components/StaggerIn';
8
+
9
+ /**
10
+ * Held while the register is reconciled against what the applicant typed.
11
+ *
12
+ * A SKELETON of the roster it becomes, not a spinner line (mirrors the web
13
+ * SDK's KeyPeoplePending): ghost cards drawn at the real cards' geometry
14
+ * reserve the space so the answer lands in place, and the shape itself says
15
+ * "a list of people is coming" where a spinner only says "busy". A blank here
16
+ * reads as "nobody needs to verify", the opposite of true. The status line is
17
+ * a polite live region; every shimmer respects the OS reduce-motion setting.
18
+ * Seconds, normally; the hook behind it gives up rather than spinning forever.
19
+ */
20
+
21
+ /** One shimmering placeholder bar. Static at 70% opacity under reduced motion. */
22
+ function GhostBar({
23
+ pulse,
24
+ width,
25
+ height,
26
+ color,
27
+ round,
28
+ style,
29
+ }: {
30
+ pulse: Animated.Value | null;
31
+ width: number | `${number}%`;
32
+ height: number;
33
+ color: string;
34
+ round?: boolean;
35
+ style?: object;
36
+ }): React.ReactElement {
37
+ return (
38
+ <Animated.View
39
+ style={{
40
+ width,
41
+ height,
42
+ backgroundColor: color,
43
+ borderRadius: round ? radius.full : 4,
44
+ opacity: pulse ?? 0.7,
45
+ ...style,
46
+ }}
47
+ />
48
+ );
49
+ }
50
+
51
+ function GhostCard({
52
+ pulse,
53
+ withLink,
54
+ }: {
55
+ pulse: Animated.Value | null;
56
+ withLink?: boolean;
57
+ }): React.ReactElement {
58
+ const { colors } = useTheme();
59
+ return (
60
+ <View
61
+ style={{
62
+ backgroundColor: colors.backgroundSecondary,
63
+ borderRadius: radius.md,
64
+ padding: spacing.md,
65
+ marginBottom: spacing.sm,
66
+ }}
67
+ >
68
+ <View style={{ flexDirection: 'row', alignItems: 'flex-start' }}>
69
+ <View style={{ flex: 1, marginRight: spacing.sm }}>
70
+ <GhostBar pulse={pulse} width="42%" height={14} color={colors.border} />
71
+ <GhostBar
72
+ pulse={pulse}
73
+ width="64%"
74
+ height={10}
75
+ color={colors.border}
76
+ style={{ marginTop: spacing.xs + 2 }}
77
+ />
78
+ </View>
79
+ <GhostBar pulse={pulse} width={76} height={22} color={colors.border} round />
80
+ </View>
81
+ {withLink ? (
82
+ <GhostBar
83
+ pulse={pulse}
84
+ width="100%"
85
+ height={38}
86
+ color={colors.primary100}
87
+ round
88
+ style={{ marginTop: spacing.sm + 4 }}
89
+ />
90
+ ) : null}
91
+ </View>
92
+ );
93
+ }
94
+
95
+ export function KeyPeoplePending(): React.ReactElement {
96
+ const { colors } = useTheme();
97
+ const reduceMotion = useReduceMotion();
98
+ const pulseValue = useRef(new Animated.Value(1)).current;
99
+
100
+ useEffect(() => {
101
+ if (reduceMotion) return undefined;
102
+ const loop = Animated.loop(
103
+ Animated.sequence([
104
+ Animated.timing(pulseValue, { toValue: 0.45, duration: 600, useNativeDriver: true }),
105
+ Animated.timing(pulseValue, { toValue: 1, duration: 600, useNativeDriver: true }),
106
+ ]),
107
+ );
108
+ loop.start();
109
+ return () => loop.stop();
110
+ }, [pulseValue, reduceMotion]);
111
+
112
+ const pulse = reduceMotion ? null : pulseValue;
113
+
114
+ return (
115
+ <View style={{ width: '100%', marginTop: spacing.lg }}>
116
+ <View
117
+ accessible
118
+ accessibilityLiveRegion="polite"
119
+ accessibilityLabel="Working out who else needs to verify. We are checking the official register for the company's directors and owners."
120
+ style={{ alignItems: 'center', marginBottom: spacing.md }}
121
+ >
122
+ <View style={{ flexDirection: 'row', alignItems: 'center' }}>
123
+ <ActivityIndicator size="small" color={colors.primary} />
124
+ <MyazaText variant="bodyMedium" style={{ fontWeight: '600', marginLeft: spacing.sm }}>
125
+ Working out who else needs to verify
126
+ </MyazaText>
127
+ </View>
128
+ {/* Naming the authority is the reassurance: the pause is the official
129
+ register being consulted, not the app hanging. */}
130
+ <MyazaText
131
+ variant="bodySmall"
132
+ color={colors.textMuted}
133
+ style={{ textAlign: 'center', marginTop: spacing.xs }}
134
+ >
135
+ We are checking the official register for the company's directors and owners.
136
+ </MyazaText>
137
+ </View>
138
+
139
+ {/* The ghost roster: geometry, not information — hidden from assistive
140
+ tech, the live region above already carries the message. */}
141
+ <View importantForAccessibility="no-hide-descendants" accessibilityElementsHidden>
142
+ <GhostBar
143
+ pulse={pulse}
144
+ width={64}
145
+ height={10}
146
+ color={colors.border}
147
+ style={{ marginBottom: spacing.xs + 2 }}
148
+ />
149
+ <GhostCard pulse={pulse} withLink />
150
+ <GhostCard pulse={pulse} />
151
+ </View>
152
+ </View>
153
+ );
154
+ }