@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,199 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import { Pressable, View } from 'react-native';
3
+
4
+ import { MyazaAlert } from '../components/MyazaAlert';
5
+ import { MyazaButton } from '../components/MyazaButton';
6
+ import { MyazaInput } from '../components/MyazaInput';
7
+ import { MyazaSelect } from '../components/MyazaSelect';
8
+ import { MyazaText } from '../components/Typography';
9
+ import { Icon } from '../components/Icon';
10
+ import { BusinessSearchResults } from './BusinessSearchResults';
11
+ import { useKyc, useKycStore, useTheme } from '../components/runtime';
12
+ import { spacing } from '../config/theme';
13
+ import type { BusinessSearchHit } from '../services/api';
14
+
15
+ // ─── Finding the company by name, because that is what people know ───────────
16
+ //
17
+ // A registration number is on a certificate in a drawer; the name is in the
18
+ // applicant's head. Asking for the number first turns the very first field
19
+ // into a search of their own filing cabinet, which is where KYB applications
20
+ // are abandoned.
21
+ //
22
+ // The search itself is FREE and read-only; only picking a result leads to the
23
+ // paid register check. It fires on an explicit submit, never on keystroke: a
24
+ // debounce spends the register's small, uncontracted rate-limit budget on
25
+ // every prefix nobody asked for. Sizes and rhythm mirror the web SDK's
26
+ // BusinessSearch (12px block gaps, 48px input/button) — keep them in lockstep.
27
+
28
+ type SearchState =
29
+ | { phase: 'idle' }
30
+ | { phase: 'searching' }
31
+ | { phase: 'results'; hits: BusinessSearchHit[]; truncated: boolean }
32
+ | { phase: 'unavailable' };
33
+
34
+ export function BusinessSearch({
35
+ country,
36
+ onPicked,
37
+ onManualEntry,
38
+ }: {
39
+ country: string;
40
+ onPicked: (hit: BusinessSearchHit) => void;
41
+ onManualEntry: () => void;
42
+ }): React.ReactElement {
43
+ const store = useKycStore();
44
+ const { colors } = useTheme();
45
+ const region = useKyc((s) => s.business.subdivisionCode);
46
+ const [query, setQuery] = useState('');
47
+ // Narrows what came back; it never replaces the search. Matches the name AND
48
+ // the number — a box inviting either that answered only one looked broken.
49
+ const [filter, setFilter] = useState('');
50
+ const [state, setState] = useState<SearchState>({ phase: 'idle' });
51
+ // Four countries file companies per state or emirate, and the provider
52
+ // searches ONE register at a time. Without the region it fails closed —
53
+ // a company filed a state away would otherwise come back "not registered".
54
+ const [regions, setRegions] = useState<{ code: string; name: string }[]>([]);
55
+
56
+ useEffect(() => {
57
+ let cancelled = false;
58
+ setRegions([]);
59
+ if (!country) return undefined;
60
+ store
61
+ .getState()
62
+ .api.businessRegions(country)
63
+ .then((r) => {
64
+ if (!cancelled) setRegions(r.regions ?? []);
65
+ })
66
+ // An empty list means "do not ask" — also the safe answer when the
67
+ // catalogue is unreachable: the search itself says so on its own.
68
+ .catch(() => undefined);
69
+ return () => {
70
+ cancelled = true;
71
+ };
72
+ }, [country, store]);
73
+
74
+ const run = async (): Promise<void> => {
75
+ const q = query.trim();
76
+ if (q.length < 2) return;
77
+ if (regions.length > 0 && !region) return;
78
+ setState({ phase: 'searching' });
79
+ try {
80
+ const res = await store.getState().api.businessSearch({
81
+ country,
82
+ query: q,
83
+ limit: 50,
84
+ ...(region ? { subdivisionCode: region } : {}),
85
+ });
86
+ const hits = res.results ?? [];
87
+ setFilter('');
88
+ setState({ phase: 'results', hits, truncated: hits.length >= 50 });
89
+ } catch {
90
+ // Deliberately NOT an empty result list. "No matches" reads as "this
91
+ // business is not registered", a claim an outage has not earned.
92
+ setState({ phase: 'unavailable' });
93
+ }
94
+ };
95
+
96
+ const searching = state.phase === 'searching';
97
+ const shown =
98
+ state.phase === 'results'
99
+ ? state.hits.filter((h) => {
100
+ const q = filter.trim().toLowerCase();
101
+ if (!q) return true;
102
+ return (
103
+ h.name.toLowerCase().includes(q) || h.registrationNumber.toLowerCase().includes(q)
104
+ );
105
+ })
106
+ : [];
107
+
108
+ return (
109
+ <View>
110
+ {regions.length > 0 && (
111
+ <View style={{ marginBottom: 12 }}>
112
+ <MyazaText variant="label" style={{ marginBottom: spacing.sm }}>
113
+ State or region of registration
114
+ </MyazaText>
115
+ <MyazaSelect
116
+ value={region || undefined}
117
+ sheetTitle="State or region"
118
+ searchable
119
+ options={regions.map((r) => ({ value: r.code, label: r.name }))}
120
+ onChange={(code) => store.getState().setBusinessField('subdivisionCode', code)}
121
+ />
122
+ </View>
123
+ )}
124
+
125
+ {/* No label: the placeholder says it, and the magnifier says what kind
126
+ of field this is — the web SDK's exact arrangement. */}
127
+ <MyazaInput
128
+ value={query}
129
+ onChangeText={setQuery}
130
+ placeholder="Search by company name"
131
+ autoCorrect={false}
132
+ prefix={<Icon name="search" size={16} color={colors.textMuted} />}
133
+ />
134
+ <View style={{ height: 12 }} />
135
+ {/* The spinner lives IN the button, as on web — a separate spinner row
136
+ pushed the results down and said the same thing twice. */}
137
+ <MyazaButton
138
+ label={searching ? 'Searching…' : 'Search'}
139
+ leadingIcon={searching ? undefined : 'search'}
140
+ loading={searching}
141
+ onPress={() => void run()}
142
+ disabled={searching || query.trim().length < 2 || (regions.length > 0 && !region)}
143
+ />
144
+
145
+ {state.phase === 'results' && state.hits.length > 0 && (
146
+ <BusinessSearchResults
147
+ country={country}
148
+ hits={state.hits}
149
+ shown={shown}
150
+ truncated={state.truncated}
151
+ filter={filter}
152
+ onFilter={setFilter}
153
+ onPicked={onPicked}
154
+ />
155
+ )}
156
+
157
+ {state.phase === 'results' && state.hits.length === 0 && (
158
+ <MyazaText variant="bodyMedium" color={colors.textSecondary} style={{ marginTop: 12 }}>
159
+ Nothing found under that name. Try a shorter version of it, or enter the details
160
+ yourself.
161
+ </MyazaText>
162
+ )}
163
+
164
+ {state.phase === 'unavailable' && (
165
+ <View style={{ marginTop: 12 }}>
166
+ <MyazaAlert
167
+ variant="warning"
168
+ title="Search is unavailable"
169
+ message="We could not reach the company register just now. Try again, or enter the details yourself and we will check them when you submit."
170
+ />
171
+ </View>
172
+ )}
173
+
174
+ {/* Always offered, not only on failure: some companies are not in the
175
+ index at all, and a dead end at the first step ends the application. */}
176
+ <Pressable
177
+ accessibilityRole="button"
178
+ onPress={onManualEntry}
179
+ hitSlop={8}
180
+ style={{
181
+ flexDirection: 'row',
182
+ alignItems: 'center',
183
+ gap: 6,
184
+ alignSelf: 'flex-start',
185
+ marginTop: 12,
186
+ }}
187
+ >
188
+ <Icon name="pencil" size={14} color={colors.textSecondary} />
189
+ <MyazaText
190
+ variant="bodyMedium"
191
+ color={colors.textSecondary}
192
+ style={{ fontWeight: '500' }}
193
+ >
194
+ Enter the details myself
195
+ </MyazaText>
196
+ </Pressable>
197
+ </View>
198
+ );
199
+ }
@@ -0,0 +1,115 @@
1
+ import React from 'react';
2
+ import { Pressable, ScrollView, View } from 'react-native';
3
+
4
+ import { CountryFlag } from '../components/CountryFlag';
5
+ import { Icon } from '../components/Icon';
6
+ import { MyazaInput } from '../components/MyazaInput';
7
+ import { MyazaText } from '../components/Typography';
8
+ import { useTheme } from '../components/runtime';
9
+ import { radius } from '../config/theme';
10
+ import type { BusinessSearchHit } from '../services/api';
11
+
12
+ // The result list: count first ("40 results" says whether to narrow before you
13
+ // start reading), a filter that narrows without re-searching, and one row per
14
+ // candidate. Split from BusinessSearch (200-line rule). Every dimension here —
15
+ // 40px filter, 12px row padding, 6px row gaps, 256px list — is the web SDK's,
16
+ // so the two render the same screen.
17
+
18
+ export function BusinessSearchResults({
19
+ country,
20
+ hits,
21
+ shown,
22
+ truncated,
23
+ filter,
24
+ onFilter,
25
+ onPicked,
26
+ }: {
27
+ country: string;
28
+ hits: BusinessSearchHit[];
29
+ shown: BusinessSearchHit[];
30
+ truncated: boolean;
31
+ filter: string;
32
+ onFilter: (value: string) => void;
33
+ onPicked: (hit: BusinessSearchHit) => void;
34
+ }): React.ReactElement {
35
+ const { colors } = useTheme();
36
+ return (
37
+ <>
38
+ {/* Count first: "40 results" says whether to narrow before you start
39
+ reading, which a bare list does not. */}
40
+ <View
41
+ style={{
42
+ flexDirection: 'row',
43
+ alignItems: 'center',
44
+ justifyContent: 'space-between',
45
+ gap: 12,
46
+ marginTop: 12,
47
+ }}
48
+ >
49
+ <MyazaText variant="bodyMedium" color={colors.textSecondary}>
50
+ {shown.length === hits.length
51
+ ? `${hits.length} results`
52
+ : `${shown.length} of ${hits.length} results`}
53
+ </MyazaText>
54
+ <View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
55
+ <MyazaText variant="bodyMedium" color={colors.textSecondary}>
56
+ Filter
57
+ </MyazaText>
58
+ <View style={{ width: 176 }}>
59
+ <MyazaInput
60
+ value={filter}
61
+ onChangeText={onFilter}
62
+ placeholder="Name or number"
63
+ autoCorrect={false}
64
+ height={40}
65
+ fontSize={14}
66
+ prefix={<Icon name="filter" size={14} color={colors.textMuted} />}
67
+ />
68
+ </View>
69
+ </View>
70
+ </View>
71
+ <ScrollView style={{ maxHeight: 256, marginTop: 12 }} nestedScrollEnabled>
72
+ {shown.map((hit) => (
73
+ <Pressable
74
+ key={`${hit.registrationNumber}-${hit.name}`}
75
+ accessibilityRole="button"
76
+ onPress={() => onPicked(hit)}
77
+ style={{
78
+ flexDirection: 'row',
79
+ alignItems: 'center',
80
+ gap: 12,
81
+ borderWidth: 1,
82
+ borderColor: colors.border,
83
+ borderRadius: radius.sm,
84
+ padding: 12,
85
+ marginBottom: 6,
86
+ }}
87
+ >
88
+ <Icon name="building-2" size={16} color={colors.textSecondary} />
89
+ <View style={{ flex: 1, minWidth: 0 }}>
90
+ <MyazaText variant="label" color={colors.textDark} numberOfLines={1}>
91
+ {hit.name}
92
+ </MyazaText>
93
+ <View style={{ flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 2 }}>
94
+ <CountryFlag country={country} size={14} />
95
+ <MyazaText variant="bodySmall" color={colors.textSecondary}>
96
+ {hit.registrationNumber}
97
+ </MyazaText>
98
+ </View>
99
+ </View>
100
+ </Pressable>
101
+ ))}
102
+ </ScrollView>
103
+ {shown.length === 0 && (
104
+ <MyazaText variant="bodyMedium" color={colors.textSecondary} style={{ marginTop: 12 }}>
105
+ Nothing here matches "{filter.trim()}". Clear the filter to see all {hits.length}.
106
+ </MyazaText>
107
+ )}
108
+ {truncated && (
109
+ <MyazaText variant="bodySmall" color={colors.textSecondary} style={{ marginTop: 6 }}>
110
+ Showing the first {hits.length}. Add more of the name to narrow it down.
111
+ </MyazaText>
112
+ )}
113
+ </>
114
+ );
115
+ }
@@ -5,79 +5,114 @@ import { spacing } from '../config/theme';
5
5
  import { useTheme } from '../components/runtime';
6
6
  import { MyazaText } from '../components/Typography';
7
7
  import { MyazaInput } from '../components/MyazaInput';
8
+ import { MyazaDateField } from '../components/MyazaDateField';
9
+ import { PhoneNumberInput } from '../components/PhoneNumberInput';
10
+ import { isValidContactEmail } from '../config/contact';
11
+ import { isValidWebsite } from '../config/website';
8
12
  import type { CompanyInfoField, CompanyInfoMode } from '../types/business';
13
+ import type { BusinessState } from '../store/state';
9
14
 
10
15
  // ---------------------------------------------------------------------------
11
16
  // The company profile a KYB workflow asks for.
12
17
  //
13
18
  // Split from BusinessDetailsStep (200-line rule). Each field's mode comes from
14
19
  // the workflow and must match the server's resolution exactly — a field marked
15
- // optional here but required there produces a 422 the user cannot act on.
16
- // Section header, captions and placeholders mirror the web SDK's
17
- // BusinessCompanyInfoFields 1:1.
20
+ // optional here but required there produces a 422 the user cannot act on. The
21
+ // last five are registry facts the applicant STATES: asked as their own answer
22
+ // rather than filled from the register, because where the two differ that is
23
+ // the finding. Field list, labels, placeholders and controls mirror the web
24
+ // SDK's BusinessCompanyInfoFields 1:1.
18
25
  // ---------------------------------------------------------------------------
19
26
 
20
- export const COMPANY_FIELD_LABELS: Record<CompanyInfoField, string> = {
21
- address: 'Registered address',
22
- email: 'Business email',
23
- phone: 'Business phone',
24
- website: 'Website',
25
- };
26
-
27
- const COMPANY_FIELD_PLACEHOLDERS: Record<CompanyInfoField, string> = {
28
- address: 'e.g. 12 Marina Road, Lagos',
29
- email: 'hello@company.com',
30
- phone: '+234 800 000 0000',
31
- website: 'company.com',
32
- };
27
+ const FIELD_DEFS: Array<{
28
+ key: CompanyInfoField;
29
+ label: string;
30
+ placeholder: string;
31
+ kind?: 'phone' | 'date' | 'email' | 'website';
32
+ }> = [
33
+ { key: 'address', label: 'Registered address', placeholder: 'e.g. 12 Marina Road, Lagos' },
34
+ { key: 'email', label: 'Business email', placeholder: 'hello@company.com', kind: 'email' },
35
+ { key: 'phone', label: 'Business phone', placeholder: '+234 800 000 0000', kind: 'phone' },
36
+ { key: 'website', label: 'Website', placeholder: 'company.com', kind: 'website' },
37
+ { key: 'dateOfIncorporation', label: 'Date of incorporation', placeholder: 'YYYY-MM-DD', kind: 'date' },
38
+ { key: 'taxId', label: 'Tax ID', placeholder: 'e.g. 01234567-0001' },
39
+ { key: 'vatNumber', label: 'VAT number', placeholder: 'e.g. NG123456789' },
40
+ { key: 'companyType', label: 'Company type', placeholder: 'e.g. Private Limited Company' },
41
+ { key: 'natureOfBusiness', label: 'Nature of business', placeholder: 'What the company does' },
42
+ ];
33
43
 
34
44
  export function CompanyInfoFields({
35
- fields,
36
- modes,
37
45
  values,
38
- missing,
39
- showErrors,
46
+ modes,
47
+ country,
40
48
  onChange,
41
49
  }: {
42
- fields: CompanyInfoField[];
50
+ values: BusinessState;
43
51
  modes: Record<CompanyInfoField, CompanyInfoMode>;
44
- values: Record<CompanyInfoField, string>;
45
- missing: CompanyInfoField[];
46
- showErrors: boolean;
52
+ /** Seeds the phone dial code: the company's country of registration. */
53
+ country?: string;
47
54
  onChange: (field: CompanyInfoField, value: string) => void;
48
55
  }): React.ReactElement | null {
49
56
  const { colors } = useTheme();
50
- if (fields.length === 0) return null;
57
+ const visible = FIELD_DEFS.filter((f) => modes[f.key] !== 'off');
58
+ if (visible.length === 0) return null;
51
59
 
52
60
  return (
53
- <>
54
- <View style={{ height: spacing.md }} />
55
- <MyazaText variant="bodyMedium" style={{ fontWeight: '600' }}>
56
- Company information
57
- </MyazaText>
61
+ <View>
62
+ <MyazaText variant="label">Company information</MyazaText>
58
63
  <MyazaText variant="bodySmall" color={colors.textSecondary} style={{ marginTop: 2 }}>
59
64
  We verify these details against the official registry record.
60
65
  </MyazaText>
61
- {fields.map((field) => (
62
- <View key={field}>
63
- <View style={{ height: spacing.sm }} />
64
- <MyazaInput
65
- label={`${COMPANY_FIELD_LABELS[field]}${modes[field] === 'required' ? ' *' : ' (optional)'}`}
66
- value={values[field]}
67
- onChangeText={(text) => onChange(field, text)}
68
- placeholder={COMPANY_FIELD_PLACEHOLDERS[field]}
69
- keyboardType={
70
- field === 'email' ? 'email-address' : field === 'phone' ? 'phone-pad' : 'default'
71
- }
72
- autoCapitalize="none"
73
- error={
74
- showErrors && missing.includes(field)
75
- ? `${COMPANY_FIELD_LABELS[field]} is required.`
76
- : null
77
- }
78
- />
79
- </View>
80
- ))}
81
- </>
66
+ {visible.map((f) => {
67
+ const required = modes[f.key] === 'required';
68
+ const value = values[f.key];
69
+ const error =
70
+ f.kind === 'email' && value !== '' && !isValidContactEmail(value)
71
+ ? 'Enter a valid email address.'
72
+ : f.kind === 'website' && value !== '' && !isValidWebsite(value)
73
+ ? 'Enter a valid website, for example company.com'
74
+ : null;
75
+ return (
76
+ <View key={f.key} style={{ marginTop: spacing.md }}>
77
+ {f.kind === 'phone' || f.kind === 'date' ? (
78
+ <MyazaText variant="label" style={{ marginBottom: spacing.sm }}>
79
+ {f.label}
80
+ {required ? ' *' : ' (optional)'}
81
+ </MyazaText>
82
+ ) : null}
83
+ {f.kind === 'phone' ? (
84
+ // The same control the phone-verification step uses: dial-code
85
+ // picker, as-you-type national formatting, E.164 out. A business
86
+ // number is a phone number, and a bare text box gets back a dozen
87
+ // different shapes of the same digits. `value` carries the
88
+ // register's number when the lookup returned one.
89
+ <PhoneNumberInput
90
+ value={value}
91
+ defaultCountry={country}
92
+ onChange={({ e164 }) => onChange('phone', e164)}
93
+ />
94
+ ) : f.kind === 'date' ? (
95
+ // A date gets the picker, not a text box — same control the
96
+ // questionnaire uses; emits strict YYYY-MM-DD.
97
+ <MyazaDateField
98
+ value={value || undefined}
99
+ placeholder={f.placeholder}
100
+ onChange={(iso) => onChange(f.key, iso)}
101
+ />
102
+ ) : (
103
+ <MyazaInput
104
+ label={`${f.label}${required ? ' *' : ' (optional)'}`}
105
+ value={value}
106
+ onChangeText={(text) => onChange(f.key, text)}
107
+ placeholder={f.placeholder}
108
+ keyboardType={f.kind === 'email' ? 'email-address' : 'default'}
109
+ autoCapitalize={f.kind === 'email' || f.kind === 'website' ? 'none' : 'words'}
110
+ error={error}
111
+ />
112
+ )}
113
+ </View>
114
+ );
115
+ })}
116
+ </View>
82
117
  );
83
118
  }
@@ -9,6 +9,7 @@ import { MyazaText } from '../components/Typography';
9
9
  import { MyazaButton } from '../components/MyazaButton';
10
10
  import { Icon } from '../components/Icon';
11
11
  import { buildConsentModel } from './consent/model';
12
+ import { resubmitNote } from '../lib/resubmit';
12
13
 
13
14
  // Consent / welcome screen — 1:1 with the web SDK's ConsentStep and the
14
15
  // Flutter SDK's ConsentScreen: shield hero, token-filled greeting, a "DURING
@@ -27,10 +28,40 @@ export function ConsentStep(): React.ReactElement {
27
28
  const { isBusiness, title, description, steps, capturesFace, recordsVideo } =
28
29
  buildConsentModel(config);
29
30
 
31
+ // A reviewer sent this applicant back. Say so, and say why — the note is the
32
+ // only thing on screen that explains a flow which has silently lost most of
33
+ // its steps. Above the hero so it is read before the instructions.
34
+ const redoNote = resubmitNote(config.resubmit);
35
+
30
36
  return (
31
37
  <View>
32
38
  <View style={{ height: spacing.sm }} />
33
39
 
40
+ {redoNote ? (
41
+ <View
42
+ style={{
43
+ flexDirection: 'row',
44
+ gap: spacing.sm,
45
+ borderRadius: radius.lg,
46
+ borderWidth: 1,
47
+ borderColor: `${colors.warning}66`,
48
+ backgroundColor: colors.warningBg,
49
+ padding: spacing.md,
50
+ marginBottom: spacing.lg,
51
+ }}
52
+ >
53
+ <Icon name="refresh" size={16} color={colors.warning} />
54
+ <View style={{ flex: 1, gap: 2 }}>
55
+ <MyazaText variant="bodyMedium" color={colors.warning}>
56
+ A few things to redo
57
+ </MyazaText>
58
+ <MyazaText variant="bodySmall" color={colors.textSecondary}>
59
+ {redoNote}
60
+ </MyazaText>
61
+ </View>
62
+ </View>
63
+ ) : null}
64
+
34
65
  {/* Shield hero — concentric tinted rings + primary badge */}
35
66
  <View style={{ alignItems: 'center' }}>
36
67
  <View style={{ width: 80, height: 80, alignItems: 'center', justifyContent: 'center' }}>
@@ -53,6 +53,7 @@ export function ContactDestinationField({
53
53
  <PhoneNumberInput
54
54
  defaultCountry={defaultCountry}
55
55
  disabled={disabled}
56
+ autoFocus
56
57
  onChange={onPhoneChange}
57
58
  />
58
59
  {error ? <ErrorLine text={error} /> : null}
@@ -1,9 +1,10 @@
1
- import React, { useCallback, useEffect, useState } from 'react';
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import { View } from 'react-native';
3
3
 
4
4
  import { spacing } from '../config/theme';
5
5
  import { useEffectiveCountry, useKyc, useKycConfig, useKycStore } from '../components/runtime';
6
6
  import { MyazaButton } from '../components/MyazaButton';
7
+ import { MyazaAlert } from '../components/MyazaAlert';
7
8
  import { ContactDestinationField } from './ContactDestinationField';
8
9
  import { ContactVerifiedPanel } from './ContactVerifiedPanel';
9
10
  import { ContactCodeStep } from './ContactCodeStep';
@@ -17,6 +18,7 @@ import {
17
18
  type PhoneOtpChannel,
18
19
  } from '../config/contact';
19
20
  import { attemptsRemaining, describeCheckError, describeSendError } from '../services/contactErrors';
21
+ import { stepAfterContactVerified } from '../lib/contact-recovery';
20
22
 
21
23
  // ---------------------------------------------------------------------------
22
24
  // Contact-verification OTP step — email or phone, one component, two mounts.
@@ -37,6 +39,7 @@ export function ContactVerificationStep({
37
39
  const country = useEffectiveCountry();
38
40
  const store = useKycStore();
39
41
  const contact = useKyc((s) => s.contact);
42
+ const serverConfig = useKyc((s) => s.serverConfig);
40
43
 
41
44
  const isEmail = channel === 'email';
42
45
  const stepConfig = isEmail ? config.emailVerification : config.phoneVerification;
@@ -69,7 +72,20 @@ export function ContactVerificationStep({
69
72
  const value = isEmail ? destination.trim() : phone.e164;
70
73
  const canSend = isEmail ? isValidContactEmail(value) : phone.isValid;
71
74
 
72
- const advance = useCallback(() => store.getState().nextStep(), [store]);
75
+ // Entered via submit recovery? (The server refused this channel's proof at
76
+ // submit — the token had expired or was already claimed.) Captured at mount:
77
+ // verifying clears the flag, and Continue must still route back to
78
+ // 'submitted' afterwards (which auto-submits with the fresh proof).
79
+ const recovery = useRef((contact.expired ?? []).includes(channel)).current;
80
+ const advance = useCallback(() => {
81
+ const target = stepAfterContactVerified({
82
+ recovery,
83
+ expired: store.getState().contact.expired ?? [],
84
+ channel,
85
+ });
86
+ if (target) store.getState().goToStep(target);
87
+ else store.getState().nextStep();
88
+ }, [store, channel, recovery]);
73
89
 
74
90
  // The sheet header is rendered by the shell and cannot see this step's state,
75
91
  // so publish what it needs to caption: the channel in play now, and the
@@ -159,12 +175,27 @@ export function ContactVerificationStep({
159
175
  />
160
176
  ) : (
161
177
  <>
178
+ {recovery && (
179
+ <>
180
+ <MyazaAlert
181
+ variant="warning"
182
+ title="Please verify again"
183
+ message={`Your earlier confirmation has expired, so please verify ${isEmail ? 'your email' : 'your number'} once more. Everything else is saved, and we will submit again straight after.`}
184
+ />
185
+ <View style={{ height: spacing.md }} />
186
+ </>
187
+ )}
162
188
  <ContactDestinationField
163
189
  isEmail={isEmail}
164
190
  email={destination}
165
191
  onEmailChange={setDestination}
166
192
  onPhoneChange={setPhone}
167
- defaultCountry={config.phoneVerification?.defaultCountry ?? country ?? undefined}
193
+ defaultCountry={
194
+ config.phoneVerification?.defaultCountry ??
195
+ country ??
196
+ serverConfig.geoCountry ??
197
+ undefined
198
+ }
168
199
  error={error}
169
200
  disabled={busy}
170
201
  />
@@ -3,6 +3,7 @@ import { Pressable, View } from 'react-native';
3
3
 
4
4
  import { radius, spacing } from '../config/theme';
5
5
  import { ID_TYPES, resolveIdTypeDefinition } from '../config/idTypes';
6
+ import { useMultiIdPlan } from '../lib/use-multi-id-plan';
6
7
  import type { IdType, IdTypeDefinition } from '../types/config';
7
8
  import {
8
9
  useEffectiveCountry,
@@ -54,6 +55,10 @@ export function IdTypeStep(): React.ReactElement {
54
55
  const store = useKycStore();
55
56
  const serverConfig = useKyc((s) => s.serverConfig);
56
57
  const selectedIdType = useKyc((s) => s.selectedIdType);
58
+ // Multi-ID: this picker is for ONE check of several. It may only offer picks
59
+ // that leave every later check something to offer — the server validates the
60
+ // same rule, so an unsafe pick would be a submission it rejects.
61
+ const plan = useMultiIdPlan();
57
62
 
58
63
  // Select AND advance in one tap. Both go through the store so `nextStep`
59
64
  // routes off the value just written — a document ID to document-capture, a
@@ -104,13 +109,15 @@ export function IdTypeStep(): React.ReactElement {
104
109
  // what makes the disabled step actually disappear from the live flow rather
105
110
  // than still offering passports and licences that then have nowhere to be
106
111
  // captured. Mirrors the web SDK's IdTypeStep.
107
- const visible = useMemo<IdTypeDefinition[]>(
108
- () =>
112
+ const visible = useMemo<IdTypeDefinition[]>(() => {
113
+ const base =
109
114
  config.enableDocumentCapture === false
110
115
  ? available.filter((t) => !t.requiresDocumentCapture)
111
- : available,
112
- [available, config.enableDocumentCapture],
113
- );
116
+ : available;
117
+ // Multi-ID: only the picks that are still free AND leave every later check
118
+ // an option. An ID already used earlier in the run is simply not offered.
119
+ return plan ? base.filter((t) => plan.safeOptions.includes(t.key)) : base;
120
+ }, [available, config.enableDocumentCapture, plan]);
114
121
 
115
122
  if (serverConfig.status === 'loading') {
116
123
  return (