@myazahq/kyc-sdk-react-native 2.5.0 → 2.6.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 (167) hide show
  1. package/README.md +151 -5
  2. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +6 -4
  3. package/app.plugin.js +89 -8
  4. package/ios/HybridMyazaFaceDetector.swift +8 -3
  5. package/package.json +14 -2
  6. package/src/MyazaBiometricAuth.tsx +119 -0
  7. package/src/components/CountryField.tsx +8 -0
  8. package/src/components/CountryRegionPicker.tsx +184 -0
  9. package/src/components/DialCodePicker.tsx +47 -68
  10. package/src/components/DialCodeRow.tsx +102 -0
  11. package/src/components/FramedMapPicker.tsx +122 -0
  12. package/src/components/GeoBadge.tsx +34 -0
  13. package/src/components/Icon.tsx +2 -185
  14. package/src/components/KycFlow.tsx +60 -32
  15. package/src/components/LineSkeleton.tsx +100 -0
  16. package/src/components/MapChrome.tsx +67 -0
  17. package/src/components/MapPinMarker.tsx +54 -0
  18. package/src/components/MapPinPicker.tsx +179 -0
  19. package/src/components/MilestoneTrack.tsx +155 -0
  20. package/src/components/MyazaInput.tsx +17 -1
  21. package/src/components/OptionRow.tsx +22 -3
  22. package/src/components/PhoneNumberInput.tsx +8 -0
  23. package/src/components/PoweredBy.tsx +5 -5
  24. package/src/components/PresenceBlocks.tsx +153 -0
  25. package/src/components/StepView.tsx +17 -0
  26. package/src/components/StickyActions.tsx +56 -0
  27. package/src/components/dialCodeRows.ts +64 -0
  28. package/src/components/icon-map.ts +176 -0
  29. package/src/components/stepHeaderMeta.tsx +42 -4
  30. package/src/config/addressCollection.ts +125 -0
  31. package/src/config/biometricOptions.ts +100 -0
  32. package/src/config/business.ts +20 -1
  33. package/src/config/businessSteps.ts +8 -2
  34. package/src/config/consentStep.ts +19 -0
  35. package/src/config/proofOfAddress.ts +54 -11
  36. package/src/config/regions.ts +24 -0
  37. package/src/config/stepOrder.ts +79 -5
  38. package/src/config/uploadLimits.ts +35 -0
  39. package/src/config/workflowMerge.ts +11 -5
  40. package/src/index.ts +54 -1
  41. package/src/lib/address-current-location.ts +171 -0
  42. package/src/lib/address-field-modes.ts +119 -0
  43. package/src/lib/address-flow.ts +172 -0
  44. package/src/lib/address-helpers.ts +48 -0
  45. package/src/lib/address-line.ts +76 -0
  46. package/src/lib/address-pin-move.ts +97 -0
  47. package/src/lib/address-step-recovery.ts +63 -0
  48. package/src/lib/authed-image.ts +78 -0
  49. package/src/lib/biometric-auth.ts +52 -0
  50. package/src/lib/biometric-copy.ts +51 -0
  51. package/src/lib/captureRing.ts +83 -0
  52. package/src/lib/country-adoption.ts +89 -0
  53. package/src/lib/inferred-country.ts +65 -0
  54. package/src/lib/livenessLayout.ts +49 -0
  55. package/src/lib/map-frame.ts +159 -0
  56. package/src/lib/map-tiles.ts +143 -0
  57. package/src/lib/poa-country-gate.ts +27 -0
  58. package/src/lib/result-copy.ts +116 -0
  59. package/src/lib/result-wait.ts +53 -0
  60. package/src/lib/review-map-surface.ts +26 -0
  61. package/src/lib/scope.ts +31 -0
  62. package/src/lib/selfie-upload-wait.ts +79 -0
  63. package/src/lib/street-view-fov.ts +42 -0
  64. package/src/lib/webview-available.ts +47 -0
  65. package/src/liveness/useLiveness.ts +6 -1
  66. package/src/presence/background-math.ts +100 -0
  67. package/src/presence/background-store.ts +82 -0
  68. package/src/presence/background.ts +165 -0
  69. package/src/presence/foreground-service.ts +193 -0
  70. package/src/presence/fs.ts +51 -0
  71. package/src/presence/geofence.ts +41 -0
  72. package/src/presence/math.ts +44 -0
  73. package/src/presence/post.ts +40 -0
  74. package/src/presence/report.ts +87 -0
  75. package/src/presence/sampler.ts +110 -0
  76. package/src/presence/status.ts +92 -0
  77. package/src/presence/store.ts +94 -0
  78. package/src/presence/tier.ts +36 -0
  79. package/src/presence/watch-wait.ts +81 -0
  80. package/src/screens/AddressCountryControl.tsx +115 -0
  81. package/src/screens/BusinessDetailsFields.tsx +3 -0
  82. package/src/screens/BusinessDetailsStep.tsx +2 -0
  83. package/src/screens/BusinessDocumentSlot.tsx +3 -2
  84. package/src/screens/BusinessDocumentsStep.tsx +4 -55
  85. package/src/screens/CompanyInfoFields.tsx +6 -1
  86. package/src/screens/ConsentStep.tsx +6 -17
  87. package/src/screens/ContactActions.tsx +54 -0
  88. package/src/screens/ContactDestinationField.tsx +4 -0
  89. package/src/screens/ContactEntryPanel.tsx +75 -0
  90. package/src/screens/ContactVerificationStep.tsx +26 -56
  91. package/src/screens/CountrySelectStep.tsx +13 -147
  92. package/src/screens/LivenessAvatar.tsx +15 -3
  93. package/src/screens/LivenessStep.tsx +154 -52
  94. package/src/screens/PoaDocumentTypeList.tsx +66 -0
  95. package/src/screens/ProofOfAddressParts.tsx +151 -0
  96. package/src/screens/ProofOfAddressStep.tsx +58 -99
  97. package/src/screens/SubmittedBadge.tsx +25 -0
  98. package/src/screens/SubmittedError.tsx +64 -0
  99. package/src/screens/SubmittedResult.tsx +117 -0
  100. package/src/screens/SubmittedStep.tsx +71 -164
  101. package/src/screens/SubmittedSuccess.tsx +127 -0
  102. package/src/screens/SubmittedWaiting.tsx +45 -0
  103. package/src/screens/address/AddressEntranceStep.tsx +161 -0
  104. package/src/screens/address/AddressIntroGate.tsx +148 -0
  105. package/src/screens/address/AddressMapStub.tsx +50 -0
  106. package/src/screens/address/AddressPinStep.tsx +178 -0
  107. package/src/screens/address/AddressReviewStep.tsx +162 -0
  108. package/src/screens/address/AddressSandboxTabs.tsx +158 -0
  109. package/src/screens/address/AddressSearchField.tsx +115 -0
  110. package/src/screens/address/AddressSearchStep.tsx +94 -0
  111. package/src/screens/address/CurrentLocationRow.tsx +135 -0
  112. package/src/screens/address/DetailsSheet.tsx +175 -0
  113. package/src/screens/address/DetailsSheetFields.tsx +184 -0
  114. package/src/screens/address/EntranceDropzone.tsx +178 -0
  115. package/src/screens/address/EntranceFraming.tsx +66 -0
  116. package/src/screens/address/EntrancePills.tsx +60 -0
  117. package/src/screens/address/FramedStreetView.tsx +155 -0
  118. package/src/screens/address/IntroDisclosures.tsx +210 -0
  119. package/src/screens/address/LabelDecisionRow.tsx +113 -0
  120. package/src/screens/address/PinSummaryRow.tsx +105 -0
  121. package/src/screens/address/ReviewAddressBand.tsx +111 -0
  122. package/src/screens/address/ReviewEntranceThumbs.tsx +64 -0
  123. package/src/screens/address/ReviewMapPicture.tsx +106 -0
  124. package/src/screens/address/SearchResults.tsx +105 -0
  125. package/src/screens/address/SearchScreen.tsx +176 -0
  126. package/src/screens/address/SkipForNow.tsx +43 -0
  127. package/src/screens/address/StreetViewChrome.tsx +81 -0
  128. package/src/screens/address/detail-values.ts +22 -0
  129. package/src/screens/address/fix-source.ts +27 -0
  130. package/src/screens/address/index.ts +8 -0
  131. package/src/screens/address/meta.ts +44 -0
  132. package/src/screens/address/use-address-flow.ts +200 -0
  133. package/src/screens/address/use-label-pin.ts +80 -0
  134. package/src/screens/address/use-pin-actions.ts +192 -0
  135. package/src/screens/biometric/BiometricAuthFlow.tsx +170 -0
  136. package/src/screens/consent/model.ts +79 -10
  137. package/src/screens/liveness/CaptureRing.tsx +91 -0
  138. package/src/screens/liveness/LivenessCamera.tsx +63 -0
  139. package/src/screens/liveness/LivenessHandover.tsx +43 -0
  140. package/src/screens/liveness/LivenessOutcome.tsx +4 -3
  141. package/src/screens/liveness/SelfiePreview.tsx +13 -4
  142. package/src/screens/liveness/ShutterFlash.tsx +25 -0
  143. package/src/screens/liveness/index.ts +4 -0
  144. package/src/screens/liveness/useSelfieUpload.ts +23 -3
  145. package/src/screens/nfc/NfcScanIllustration.tsx +7 -18
  146. package/src/screens/useAddressPhotoAttach.ts +77 -0
  147. package/src/screens/useBusinessDocumentAttach.ts +93 -0
  148. package/src/screens/usePoaAttach.ts +12 -15
  149. package/src/services/api-biometric.ts +37 -0
  150. package/src/services/api-types-biometric.ts +41 -0
  151. package/src/services/api-types.ts +93 -1
  152. package/src/services/api-verify-types.ts +37 -0
  153. package/src/services/api.ts +133 -3
  154. package/src/services/deviceMetadata.ts +1 -1
  155. package/src/services/location.ts +234 -0
  156. package/src/services/uploadErrors.ts +30 -0
  157. package/src/services/workflowGate.ts +7 -0
  158. package/src/store/address.ts +97 -0
  159. package/src/store/derive.ts +29 -4
  160. package/src/store/kycStore.ts +112 -3
  161. package/src/store/serverConfig.ts +18 -0
  162. package/src/store/session.ts +23 -4
  163. package/src/store/state.ts +134 -0
  164. package/src/store/submit.ts +18 -1
  165. package/src/types/config.ts +48 -1
  166. package/src/types/verification.ts +12 -0
  167. package/src/types/workflow.ts +97 -1
@@ -12,6 +12,12 @@ import { SubmittedStep } from '../screens/SubmittedStep';
12
12
  import { QuestionnaireStep } from '../screens/QuestionnaireStep';
13
13
  import { ContactVerificationStep } from '../screens/ContactVerificationStep';
14
14
  import { ProofOfAddressStep } from '../screens/ProofOfAddressStep';
15
+ import {
16
+ AddressEntranceStep,
17
+ AddressPinStep,
18
+ AddressReviewStep,
19
+ AddressSearchStep,
20
+ } from '../screens/address';
15
21
  import { CountrySelectStep } from '../screens/CountrySelectStep';
16
22
  import { BusinessDetailsStep } from '../screens/BusinessDetailsStep';
17
23
  import { BusinessKeyPeopleStep } from '../screens/BusinessKeyPeopleStep';
@@ -78,6 +84,17 @@ function stepScreen(step: KYCStep, onClose: () => void): React.ReactElement {
78
84
  return <ContactVerificationStep key="contact-phone" channel="phone" />;
79
85
  case 'proof-of-address':
80
86
  return <ProofOfAddressStep />;
87
+ // The address flow, in order: find it, confirm it, show it, commit it.
88
+ // 'address-collection' is the PIN step and keeps that wire name so older
89
+ // session progress restores cleanly.
90
+ case 'address-search':
91
+ return <AddressSearchStep />;
92
+ case 'address-collection':
93
+ return <AddressPinStep />;
94
+ case 'address-entrance':
95
+ return <AddressEntranceStep />;
96
+ case 'address-review':
97
+ return <AddressReviewStep />;
81
98
  case 'country-select':
82
99
  return <CountrySelectStep />;
83
100
  case 'business-details':
@@ -0,0 +1,56 @@
1
+ import React from 'react';
2
+ import { ScrollView, StyleSheet, View } from 'react-native';
3
+
4
+ import { spacing } from '../config/theme';
5
+ import { useTheme } from './runtime';
6
+
7
+ /**
8
+ * A step's primary actions, held at the bottom edge of a viewport-filling
9
+ * body. Mirrors the web SDK's StickyActions.
10
+ *
11
+ * A step that carries a map or a street panorama fills a phone with a surface
12
+ * that OWNS every touch: dragging it moves the map, never the page, so on a
13
+ * small screen the Continue button beneath it could only be reached by finding
14
+ * a strip of margin to scroll on. Holding the actions under a bounded scroll
15
+ * view means the applicant never has to get past the map to move on, and
16
+ * everything else still scrolls under them.
17
+ *
18
+ * Needs a BOUNDED parent: the step must be listed in KycFlow's fillsViewport
19
+ * set, where the sheet hands it a flex:1 box padded `spacing.md`. The footer's
20
+ * negative margins cancel that padding so the bar meets the sheet's edge, and
21
+ * the same padding is restored inside so the buttons keep their gutter.
22
+ */
23
+ export function StickyActions({
24
+ children,
25
+ actions,
26
+ }: {
27
+ children: React.ReactNode;
28
+ actions: React.ReactNode;
29
+ }): React.ReactElement {
30
+ const { colors } = useTheme();
31
+ return (
32
+ <View style={{ flex: 1 }}>
33
+ <ScrollView
34
+ style={{ flex: 1 }}
35
+ contentContainerStyle={{ paddingBottom: spacing.sm }}
36
+ keyboardShouldPersistTaps="handled"
37
+ >
38
+ {children}
39
+ </ScrollView>
40
+ <View
41
+ style={{
42
+ marginHorizontal: -spacing.md,
43
+ marginBottom: -spacing.md,
44
+ paddingHorizontal: spacing.md,
45
+ paddingTop: spacing.sm,
46
+ paddingBottom: spacing.md,
47
+ backgroundColor: colors.background,
48
+ borderTopWidth: StyleSheet.hairlineWidth,
49
+ borderTopColor: colors.border,
50
+ }}
51
+ >
52
+ {actions}
53
+ </View>
54
+ </View>
55
+ );
56
+ }
@@ -0,0 +1,64 @@
1
+ import { groupCountriesByRegion, pinGeoRow } from '../config/regions';
2
+ import type { DialCodeOption } from './DialCodePicker';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // What the country sheet lists, as data. Extracted from DialCodePicker
6
+ // (200-line rule) so the FlatList only renders: the search, the pinned geo
7
+ // row and the optional REGION grouping all decide here, and a unit test reads
8
+ // the result without mounting a sheet. Grouping is what the address-scope
9
+ // country control asks for (user decision 2026-09-06): its sheet reads like
10
+ // the web's region menu and the country-select step — a continent header,
11
+ // then its countries A–Z — rather than one 240-row alphabet. Mirrors
12
+ // Flutter's dial_code_rows.dart.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export type DialCodeItem =
16
+ | { kind: 'row'; option: DialCodeOption }
17
+ | { kind: 'header'; region: string };
18
+
19
+ /**
20
+ * Match the name, the ISO code, or the dial code with or without its '+',
21
+ * because people search for "+234", "234" and "Nigeria" in equal measure.
22
+ */
23
+ export function filterDialCodeOptions(options: DialCodeOption[], query: string): DialCodeOption[] {
24
+ const q = query.trim().toLowerCase();
25
+ if (!q) return options;
26
+ const bare = q.replace(/^\+/, '');
27
+ return options.filter(
28
+ (o) =>
29
+ o.name.toLowerCase().includes(q) ||
30
+ o.code.toLowerCase() === q ||
31
+ (o.dialCode != null && o.dialCode.replace(/^\+/, '').startsWith(bare)),
32
+ );
33
+ }
34
+
35
+ /**
36
+ * The sheet's items over the ALREADY-FILTERED options: where they appear to be
37
+ * first (lifted out of the alphabet, still subject to the search), then the
38
+ * rest — flat in the given order, or under region headers when `grouped`.
39
+ */
40
+ export function buildDialCodeItems(
41
+ visible: DialCodeOption[],
42
+ geoCountry: string | null | undefined,
43
+ grouped: boolean,
44
+ ): { geo: DialCodeOption | null; items: DialCodeItem[] } {
45
+ const { pinned: geo, rest } = pinGeoRow(visible, geoCountry, (o) => o.code);
46
+ const items: DialCodeItem[] = geo ? [{ kind: 'row', option: geo }] : [];
47
+ if (!grouped) {
48
+ for (const option of rest) items.push({ kind: 'row', option });
49
+ return { geo, items };
50
+ }
51
+ const byCode = new Map(rest.map((o) => [o.code.toUpperCase(), o]));
52
+ for (const group of groupCountriesByRegion(rest.map((o) => o.code))) {
53
+ items.push({ kind: 'header', region: group.region });
54
+ for (const entry of group.countries) {
55
+ const option = byCode.get(entry.code);
56
+ if (option) items.push({ kind: 'row', option });
57
+ }
58
+ }
59
+ return { geo, items };
60
+ }
61
+
62
+ export function dialCodeItemKey(item: DialCodeItem): string {
63
+ return item.kind === 'header' ? `region:${item.region}` : item.option.code;
64
+ }
@@ -0,0 +1,176 @@
1
+ import {
2
+ Info, BadgeCheck, BookUser, Building2, Stamp, Calendar, Camera, CameraOff, ChevronDown,
3
+ ChevronLeft, ChevronRight, Car, Check, CircleAlert, Contact, CreditCard, Fingerprint,
4
+ FileText, FlaskConical, IdCard, Image as ImageIcon, Landmark, Lightbulb, LocateFixed, MapPin,
5
+ MapPinHouse, MapPinCheck, MapPinned, Radar, BellRing, SlidersHorizontal, Minus, Globe, House,
6
+ Mail, Maximize2, Lock, MessageCircle, MessageSquare, Moon, MoveLeft, Nfc, Filter, Pencil,
7
+ PencilLine, Smartphone, RefreshCw, RefreshCcw, ScanFace, ScanLine, Search, Zap, ZapOff,
8
+ CircleDashed, CircleCheck, CircleHelp, CircleX, ShieldCheck, Sun, Timer, Upload, Plus,
9
+ UserRound, UserRoundPlus, Link2, Copy, Share2, UsersRound, VideoOff, Vote, X,
10
+ type LucideIcon
11
+ } from 'lucide-react-native';
12
+
13
+ // Centralised icon set — the SAME Lucide icons the Flutter SDK uses
14
+ // (`lucide_icons_flutter`) and the web SDK uses (`lucide-react`), so the
15
+ // glyphs are pixel-identical across all three platforms. The component that
16
+ // draws them is Icon.tsx (200-line split).
17
+
18
+ export type IconName =
19
+ // chrome
20
+ | 'close'
21
+ | 'back'
22
+ | 'moon'
23
+ | 'sun'
24
+ | 'check'
25
+ | 'lock'
26
+ | 'alert'
27
+ | 'flask'
28
+ | 'refresh'
29
+ | 'refresh-ccw'
30
+ | 'maximize'
31
+ | 'calendar'
32
+ | 'image'
33
+ | 'chevron-left'
34
+ | 'chevron-right'
35
+ | 'chevron-down'
36
+ // consent hero + process steps
37
+ | 'shield'
38
+ | 'badge-check'
39
+ | 'circle-dashed'
40
+ | 'circle-check'
41
+ | 'circle-help'
42
+ | 'circle-x'
43
+ | 'building-2'
44
+ | 'stamp'
45
+ | 'user'
46
+ | 'user-plus'
47
+ | 'plus'
48
+ | 'info'
49
+ | 'users'
50
+ | 'scan-line'
51
+ | 'scan-face'
52
+ | 'nfc'
53
+ | 'filter'
54
+ | 'pencil'
55
+ | 'pencil-line'
56
+ | 'search'
57
+ | 'zap'
58
+ | 'link'
59
+ | 'copy'
60
+ | 'share'
61
+ | 'zap-off'
62
+ | 'timer'
63
+ // id-type glyphs
64
+ | 'fingerprint'
65
+ | 'credit-card'
66
+ | 'globe'
67
+ | 'car'
68
+ | 'vote'
69
+ | 'file-text'
70
+ | 'house'
71
+ | 'landmark'
72
+ | 'id-card'
73
+ | 'contact'
74
+ | 'passport'
75
+ | 'camera'
76
+ | 'camera-off'
77
+ | 'video-off'
78
+ | 'upload'
79
+ | 'x'
80
+ // address collection (map picker + the presence milestone track)
81
+ | 'map-pin'
82
+ | 'map-pin-house'
83
+ | 'map-pin-check'
84
+ | 'map-pinned'
85
+ | 'radar'
86
+ | 'bell-ring'
87
+ | 'sliders'
88
+ | 'locate'
89
+ | 'minus'
90
+ | 'lightbulb'
91
+ // contact verification (OTP channels + step footer)
92
+ | 'mail'
93
+ | 'smartphone'
94
+ | 'message-square'
95
+ | 'message-circle';
96
+
97
+ // Maps each name to its Lucide component. Names mirror the Flutter SDK exactly:
98
+ // shieldCheck · badgeCheck · userRound · scanLine · scanFace · fingerprint ·
99
+ // creditCard · globe · car · vote · fileText (+ chrome: X / ArrowLeft /
100
+ // Moon / Sun / Check / Lock / CircleAlert / RefreshCw / Camera).
101
+ export const ICONS: Record<IconName, LucideIcon> = {
102
+ close: X,
103
+ back: MoveLeft, // longer-shaft left arrow, matching Flutter's keyboard_backspace
104
+ moon: Moon,
105
+ sun: Sun,
106
+ check: Check,
107
+ lock: Lock,
108
+ alert: CircleAlert,
109
+ // The environment banner's mark, shared with the web SDK (same lucide glyph)
110
+ // and mirrored by Flutter's Icons.science_outlined.
111
+ flask: FlaskConical,
112
+ refresh: RefreshCw,
113
+ 'refresh-ccw': RefreshCcw,
114
+ maximize: Maximize2,
115
+ calendar: Calendar,
116
+ image: ImageIcon,
117
+ 'chevron-left': ChevronLeft,
118
+ 'chevron-right': ChevronRight,
119
+ 'chevron-down': ChevronDown,
120
+ shield: ShieldCheck,
121
+ 'badge-check': BadgeCheck,
122
+ 'circle-dashed': CircleDashed,
123
+ 'circle-check': CircleCheck,
124
+ 'circle-help': CircleHelp,
125
+ 'circle-x': CircleX,
126
+ 'building-2': Building2,
127
+ stamp: Stamp,
128
+ user: UserRound,
129
+ info: Info,
130
+ 'user-plus': UserRoundPlus,
131
+ plus: Plus,
132
+ link: Link2,
133
+ copy: Copy,
134
+ share: Share2,
135
+ users: UsersRound,
136
+ 'scan-line': ScanLine,
137
+ 'scan-face': ScanFace,
138
+ nfc: Nfc,
139
+ filter: Filter,
140
+ pencil: Pencil,
141
+ 'pencil-line': PencilLine,
142
+ search: Search,
143
+ zap: Zap,
144
+ 'zap-off': ZapOff,
145
+ timer: Timer,
146
+ fingerprint: Fingerprint,
147
+ 'credit-card': CreditCard,
148
+ globe: Globe,
149
+ car: Car,
150
+ vote: Vote,
151
+ 'file-text': FileText,
152
+ house: House,
153
+ landmark: Landmark,
154
+ 'map-pin': MapPin,
155
+ 'map-pin-house': MapPinHouse,
156
+ 'map-pin-check': MapPinCheck,
157
+ 'map-pinned': MapPinned,
158
+ radar: Radar,
159
+ 'bell-ring': BellRing,
160
+ sliders: SlidersHorizontal,
161
+ locate: LocateFixed,
162
+ minus: Minus,
163
+ 'id-card': IdCard,
164
+ contact: Contact,
165
+ passport: BookUser,
166
+ camera: Camera,
167
+ 'camera-off': CameraOff,
168
+ 'video-off': VideoOff,
169
+ upload: Upload,
170
+ x: X,
171
+ lightbulb: Lightbulb,
172
+ mail: Mail,
173
+ smartphone: Smartphone,
174
+ 'message-square': MessageSquare,
175
+ 'message-circle': MessageCircle,
176
+ };
@@ -1,10 +1,11 @@
1
1
  import { ID_TYPES } from '../config/idTypes';
2
- import { poaMaxAgeDays } from '../config/proofOfAddress';
2
+ import { poaDocumentTypes, poaMaxAgeDays, poaNamePolicy } from '../config/proofOfAddress';
3
3
  import { documentCaptureMeta } from '../screens/DocumentCaptureStep';
4
4
  import { questionnaireMeta } from '../screens/QuestionnaireStep';
5
5
  import { contactCodeLength, contactMeta, type ContactChallenge } from '../config/contact';
6
6
 
7
7
  import { proofOfAddressMeta } from '../screens/ProofOfAddressStep';
8
+ import { addressStepMeta } from '../screens/address';
8
9
  import { countrySelectMeta } from '../screens/CountrySelectStep';
9
10
  import { businessDetailsMeta } from '../screens/BusinessDetailsStep';
10
11
  import { businessKeyPeopleMeta } from '../screens/BusinessKeyPeopleStep';
@@ -34,13 +35,36 @@ export interface StepHeaderContext {
34
35
  documentCapturePhase: DocumentCapturePhase;
35
36
  /** Outstanding contact code — lets the header describe the OTP in flight. */
36
37
  contactChallenge: ContactChallenge | null;
38
+ /**
39
+ * The presence primer is on screen in place of the address step's body.
40
+ *
41
+ * It carries its own title, so the step's would sit above it saying something
42
+ * else about a screen that is not showing. Blanking the header leaves just
43
+ * the back arrow, the way the consent step does.
44
+ */
45
+ addressIntroPending?: boolean;
46
+ /** The entrance step is framing street imagery rather than asking for a photo. */
47
+ addressEntranceFraming?: boolean;
48
+ /** The proof-of-address kind the applicant has picked, so the header can ask
49
+ * for what the workflow's name rule wants on THAT document. */
50
+ poaDocumentType?: string | null;
37
51
  }
38
52
 
39
53
  export function stepHeaderMeta(
40
54
  currentStep: KYCStep,
41
- { config, country, selectedIdType, documentCapturePhase, contactChallenge }: StepHeaderContext,
55
+ {
56
+ config,
57
+ country,
58
+ selectedIdType,
59
+ documentCapturePhase,
60
+ contactChallenge,
61
+ addressIntroPending,
62
+ addressEntranceFraming,
63
+ poaDocumentType,
64
+ }: StepHeaderContext,
42
65
  ): { title: string; description: string | null } {
43
66
  const label = labelFor(country, selectedIdType);
67
+ if (addressIntroPending) return { title: '', description: null };
44
68
  switch (currentStep) {
45
69
  case 'consent':
46
70
  return { title: '', description: null as string | null };
@@ -68,8 +92,22 @@ export function stepHeaderMeta(
68
92
  codeLength: contactCodeLength(config.phoneVerification),
69
93
  challenge: contactChallenge,
70
94
  });
71
- case 'proof-of-address':
72
- return proofOfAddressMeta(poaMaxAgeDays(config.proofOfAddress));
95
+ case 'proof-of-address': {
96
+ // Word the ask by the rule the server will judge THIS document under:
97
+ // the picked kind, else the first the country offers (what the screen
98
+ // preselects). Under `off` the header stops asking for the name.
99
+ const poa = config.proofOfAddress;
100
+ const kind = poaDocumentType ?? poaDocumentTypes(poa, country)[0];
101
+ return proofOfAddressMeta(
102
+ poaMaxAgeDays(poa),
103
+ poaNamePolicy(poa, country, kind as Parameters<typeof poaNamePolicy>[2]) !== 'off',
104
+ );
105
+ }
106
+ case 'address-search':
107
+ case 'address-collection':
108
+ case 'address-entrance':
109
+ case 'address-review':
110
+ return addressStepMeta(currentStep, config.subjectType === 'business', { framing: addressEntranceFraming });
73
111
  case 'country-select':
74
112
  return countrySelectMeta;
75
113
  case 'business-details':
@@ -0,0 +1,125 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Address Intelligence — the pure half.
3
+ //
4
+ // A smart address is a map pin (+ optional door photo and directions) the
5
+ // server corroborates against the evidence it already holds. The result is a
6
+ // SOFT sub-result: it never changes the verification's own status, it feeds
7
+ // decisioning. So nothing here should block a user beyond what the config
8
+ // explicitly requires (`requirePin`, required photo/directions).
9
+ //
10
+ // Mirrors the web SDK's address handling — keep the two in lockstep.
11
+ // ---------------------------------------------------------------------------
12
+
13
+ import { addressFlowOptions, type AddressFlowOptions } from '../lib/address-flow';
14
+ import { requiredPrefillSubmission } from '../lib/address-field-modes';
15
+ import { isBusinessFlow } from './business';
16
+ import type { SubjectType, WorkflowBusinessConfig } from '../types/business';
17
+ import type { AddressCollectionConfig } from '../types/workflow';
18
+ import type { AddressState } from '../store/state';
19
+ import type { VerifyRequest } from '../services/api-verify-types';
20
+
21
+ export type AddressFieldMode = 'off' | 'optional' | 'required';
22
+
23
+ /** Whether the step is part of the flow. */
24
+ export function hasAddressCollectionStep(
25
+ address: AddressCollectionConfig | undefined | null,
26
+ ): boolean {
27
+ return address?.enabled === true;
28
+ }
29
+
30
+ /**
31
+ * Which address screens this flow has — ONE call, read by the step-order
32
+ * builder AND by the address steps themselves, so the two can never disagree
33
+ * about which screens exist.
34
+ *
35
+ * KYB collapses to the single premises step: the pin plus its directions ARE
36
+ * the capture, and the business flow already has its own section rhythm.
37
+ */
38
+ export function addressFlowFor(
39
+ config: {
40
+ subjectType?: SubjectType;
41
+ business?: WorkflowBusinessConfig;
42
+ addressCollection?: AddressCollectionConfig;
43
+ },
44
+ /** The server offers a forward-search backend. */
45
+ serverSearch: boolean,
46
+ /** SANDBOX keys stub the vendor surfaces (addressVendorsStubbed). */
47
+ stubbed = false,
48
+ /** A maps frame URL this install can render (a WebView is installed): the
49
+ * framed street-view page carries the panorama for it. */
50
+ hasStreetViewFrame = false,
51
+ ): AddressFlowOptions {
52
+ if (isBusinessFlow(config)) {
53
+ return { searchAvailable: false, photoMode: 'off', streetViewOffered: false };
54
+ }
55
+ return addressFlowOptions({
56
+ photo: config.addressCollection?.photo,
57
+ streetView: config.addressCollection?.streetView,
58
+ serverSearch: serverSearch && !stubbed,
59
+ // The builder's live preview and the in-document Google key are web-only
60
+ // surfaces, so neither flag can ever be true here. Passing them
61
+ // explicitly keeps the rule a mirror.
62
+ previewMode: false,
63
+ hasGoogleKey: false,
64
+ hasStreetViewFrame,
65
+ });
66
+ }
67
+
68
+ // The old single-screen Continue gate (canContinueAddress and the two mode
69
+ // helpers that fed it) was deleted with the four-step flow: each step now
70
+ // gates itself, and a helper stating the old combined rule is a trap for
71
+ // whoever wires it back up. The Flutter port removed its copies for the same
72
+ // reason.
73
+
74
+ /**
75
+ * The wire block. `config` is the flow's address step: a workflow-REQUIRED
76
+ * field the applicant left on its map prefill rides that prefill (they saw
77
+ * it filled and confirmed by continuing, and the server 422s a required
78
+ * field that never arrives). Session progress passes no config and stays
79
+ * raw, so a resume never confuses a prefill with a claim.
80
+ */
81
+ export function addressPayload(
82
+ address: AddressState,
83
+ config?: AddressCollectionConfig | null,
84
+ ): NonNullable<VerifyRequest['address']> {
85
+ return {
86
+ lat: address.lat,
87
+ lng: address.lng,
88
+ // The line the applicant CONFIRMED: the server prefers it over its own
89
+ // reverse geocode, whose coverage drops whole streets in our markets.
90
+ ...(address.label?.trim() ? { label: address.label.trim() } : {}),
91
+ ...(typeof address.accuracy === 'number' ? { accuracy: address.accuracy } : {}),
92
+ ...(address.directions.trim() ? { directions: address.directions.trim() } : {}),
93
+ ...(address.propertyName.trim() ? { propertyName: address.propertyName.trim() } : {}),
94
+ ...(address.propertyNumber.trim() ? { propertyNumber: address.propertyNumber.trim() } : {}),
95
+ ...(address.street?.trim() ? { street: address.street.trim() } : {}),
96
+ // The rest of the edit-details form — claims, sent only when typed.
97
+ ...(address.unit?.trim() ? { unit: address.unit.trim() } : {}),
98
+ ...(address.neighbourhood?.trim() ? { neighbourhood: address.neighbourhood.trim() } : {}),
99
+ ...(address.city?.trim() ? { city: address.city.trim() } : {}),
100
+ ...(address.state?.trim() ? { state: address.state.trim() } : {}),
101
+ ...(address.postcode?.trim() ? { postcode: address.postcode.trim() } : {}),
102
+ // Required-but-untouched prefills, LAST among the typed fields: the helper
103
+ // never carries a typed value, so nothing above is overridden.
104
+ ...requiredPrefillSubmission(config, address),
105
+ ...(address.streetView ? { streetView: address.streetView } : {}),
106
+ ...(typeof address.deviceLat === 'number' && typeof address.deviceLng === 'number'
107
+ ? {
108
+ deviceLat: address.deviceLat,
109
+ deviceLng: address.deviceLng,
110
+ ...(typeof address.deviceAccuracy === 'number'
111
+ ? { deviceAccuracy: address.deviceAccuracy }
112
+ : {}),
113
+ ...(address.capturedAt ? { capturedAt: address.capturedAt } : {}),
114
+ }
115
+ : {}),
116
+ };
117
+ }
118
+
119
+ /** Accepted door-photo types — an image, never a PDF. */
120
+ export const ADDRESS_PHOTO_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'] as const;
121
+
122
+ export function isAcceptedAddressPhotoMimeType(mimeType: string | undefined): boolean {
123
+ const base = (mimeType?.split(';')[0] ?? '').trim().toLowerCase();
124
+ return (ADDRESS_PHOTO_MIME_TYPES as readonly string[]).includes(base);
125
+ }
@@ -0,0 +1,100 @@
1
+ import { configScope } from '../lib/scope';
2
+
3
+ // ─── The biometric scopes' flow options ─────────────────────────────────────
4
+ //
5
+ // Mirrors the server's lib/workflows/biometric-options.ts; keep the defaults
6
+ // in lockstep. All three are UX policy this SDK enforces (user decision
7
+ // 2026-09-07, React Native first). A published workflow sets them in the
8
+ // builder's Presence Intelligence panel; a prop-configured mount passes the
9
+ // same `biometric` block.
10
+ //
11
+ // selfieReview Show the captured selfie with Retake and Continue before
12
+ // submitting. OFF by default on both biometric scopes: a
13
+ // re-authentication is a few-second check, and a review
14
+ // screen is a stop in the middle of it. The liveness step
15
+ // hands straight over once the ring has closed.
16
+ // resultDelivery WHERE the verdict lands. 'both' (the default) and 'app'
17
+ // both hold the person on one loading screen from the
18
+ // shutter to the verdict, polling the publishable status
19
+ // endpoint until the check settles, because a
20
+ // re-authentication is answered NOW or it is useless; they
21
+ // differ only on the SERVER, which sends no webhook for an
22
+ // 'app' check. 'webhook' is the fire-and-forget model every
23
+ // other flow runs. Enrolment has no verdict to deliver, so
24
+ // it never waits.
25
+ // doneButton Whether the final screen carries a Done button (default
26
+ // ON). Off when the host app dismisses the flow itself from
27
+ // `onResult` (or `onSubmit` on a webhook delivery), so the
28
+ // person is never shown a button the app is about to act
29
+ // for. The screen then stays until the host closes the SDK.
30
+
31
+ /** One screen's words. A field absent (or blank) means the SDK's default
32
+ * shows. `{firstName}` / `{lastName}` tokens fill from `userData`, exactly as
33
+ * the consent and success copy do. */
34
+ export interface BiometricCopyText {
35
+ title?: string;
36
+ description?: string;
37
+ }
38
+
39
+ /**
40
+ * The org's own words on the biometric screens (the builder's "Face check
41
+ * screens" fields; user decision 2026-09-07). `waiting` is the ONE loading
42
+ * screen from the shutter to the verdict, on both scopes; `verified` and
43
+ * `declined` are the in-flow verdict screens, so they apply to a
44
+ * re-authentication only (publish refuses them on enrolment, which shows no
45
+ * verdict). A `declined` description wins over the server's reason: the org
46
+ * chose to say that. Resolved for rendering by lib/biometric-copy.ts.
47
+ */
48
+ export interface BiometricCopy {
49
+ waiting?: BiometricCopyText;
50
+ verified?: BiometricCopyText;
51
+ declined?: BiometricCopyText;
52
+ }
53
+
54
+ export interface BiometricFlowConfig {
55
+ selfieReview?: boolean;
56
+ resultDelivery?: 'app' | 'webhook' | 'both';
57
+ doneButton?: boolean;
58
+ copy?: BiometricCopy;
59
+ }
60
+
61
+ export interface BiometricFlowOptions {
62
+ selfieReview: boolean;
63
+ /** Null on enrolment: nothing is delivered, so there is nothing to wait for. */
64
+ resultDelivery: 'app' | 'webhook' | 'both' | null;
65
+ doneButton: boolean;
66
+ }
67
+
68
+ type BiometricConfigLike = { scope?: string; biometric?: BiometricFlowConfig | null };
69
+
70
+ /** The effective options, or null when the flow is not a biometric scope. */
71
+ export function biometricFlowOptions(config: BiometricConfigLike): BiometricFlowOptions | null {
72
+ const scope = configScope(config);
73
+ if (scope !== 'biometric-authentication' && scope !== 'biometric-enrollment') return null;
74
+ const block = config.biometric ?? {};
75
+ return {
76
+ selfieReview: block.selfieReview ?? false,
77
+ resultDelivery: scope === 'biometric-authentication' ? (block.resultDelivery ?? 'both') : null,
78
+ doneButton: block.doneButton ?? true,
79
+ };
80
+ }
81
+
82
+ /** Whether the liveness step shows the selfie review before handing over. A
83
+ * full verification always does; only the biometric scopes can switch it off. */
84
+ export function showsSelfieReview(config: BiometricConfigLike): boolean {
85
+ return biometricFlowOptions(config)?.selfieReview ?? true;
86
+ }
87
+
88
+ /** Whether the submitted step waits for the verdict in the flow ('app' and
89
+ * 'both') rather than leaving it to the webhook. Only a re-authentication
90
+ * ever does. */
91
+ export function waitsForResult(config: BiometricConfigLike): boolean {
92
+ const delivery = biometricFlowOptions(config)?.resultDelivery;
93
+ return delivery === 'app' || delivery === 'both';
94
+ }
95
+
96
+ /** Whether the final screen carries a Done button. Always on off the
97
+ * biometric scopes; only they can hide it. */
98
+ export function showsDoneButton(config: BiometricConfigLike): boolean {
99
+ return biometricFlowOptions(config)?.doneButton ?? true;
100
+ }
@@ -36,6 +36,20 @@ export const BUSINESS_PRODUCTS: readonly BusinessProductDef[] = [
36
36
  inputLabel: 'Registration number',
37
37
  placeholder: 'e.g. RC0000000',
38
38
  },
39
+ {
40
+ key: 'business-address',
41
+ label: 'Business Address',
42
+ inputLabel: 'Registration number',
43
+ placeholder: 'e.g. 201133333323',
44
+ countries: ['ZA'],
45
+ },
46
+ {
47
+ key: 'business-filings',
48
+ label: 'Company Filings',
49
+ inputLabel: 'Registration number',
50
+ placeholder: 'e.g. CI-ABJ-03-2023-B17-00120',
51
+ countries: ['CI'],
52
+ },
39
53
  {
40
54
  key: 'business-tax',
41
55
  label: 'Business + Tax ID',
@@ -91,7 +105,12 @@ export function businessProductsForCountry(
91
105
  ): string[] {
92
106
  const offered = businessProductsFor(business).filter((key) => {
93
107
  const def = BUSINESS_PRODUCTS.find((p) => p.key === key);
94
- return !def?.countries || def.countries.includes(country);
108
+ // A key this build does not know is HIDDEN, never offered everywhere: the
109
+ // server rejects a wrong-country pick at verify (product_unsupported), so
110
+ // over-offering dead-ends the applicant, while hiding degrades to the
111
+ // standard lookup until the SDK updates.
112
+ if (!def) return false;
113
+ return !def.countries || def.countries.includes(country);
95
114
  });
96
115
  return offered.length > 0 ? offered : [DEFAULT_BUSINESS_PRODUCT];
97
116
  }