@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.
- package/README.md +151 -5
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +6 -4
- package/app.plugin.js +89 -8
- package/ios/HybridMyazaFaceDetector.swift +8 -3
- package/package.json +14 -2
- package/src/MyazaBiometricAuth.tsx +119 -0
- package/src/components/CountryField.tsx +8 -0
- package/src/components/CountryRegionPicker.tsx +184 -0
- package/src/components/DialCodePicker.tsx +47 -68
- package/src/components/DialCodeRow.tsx +102 -0
- package/src/components/FramedMapPicker.tsx +122 -0
- package/src/components/GeoBadge.tsx +34 -0
- package/src/components/Icon.tsx +2 -185
- package/src/components/KycFlow.tsx +60 -32
- package/src/components/LineSkeleton.tsx +100 -0
- package/src/components/MapChrome.tsx +67 -0
- package/src/components/MapPinMarker.tsx +54 -0
- package/src/components/MapPinPicker.tsx +179 -0
- package/src/components/MilestoneTrack.tsx +155 -0
- package/src/components/MyazaInput.tsx +17 -1
- package/src/components/OptionRow.tsx +22 -3
- package/src/components/PhoneNumberInput.tsx +8 -0
- package/src/components/PoweredBy.tsx +5 -5
- package/src/components/PresenceBlocks.tsx +153 -0
- package/src/components/StepView.tsx +17 -0
- package/src/components/StickyActions.tsx +56 -0
- package/src/components/dialCodeRows.ts +64 -0
- package/src/components/icon-map.ts +176 -0
- package/src/components/stepHeaderMeta.tsx +42 -4
- package/src/config/addressCollection.ts +125 -0
- package/src/config/biometricOptions.ts +100 -0
- package/src/config/business.ts +20 -1
- package/src/config/businessSteps.ts +8 -2
- package/src/config/consentStep.ts +19 -0
- package/src/config/proofOfAddress.ts +54 -11
- package/src/config/regions.ts +24 -0
- package/src/config/stepOrder.ts +79 -5
- package/src/config/uploadLimits.ts +35 -0
- package/src/config/workflowMerge.ts +11 -5
- package/src/index.ts +54 -1
- package/src/lib/address-current-location.ts +171 -0
- package/src/lib/address-field-modes.ts +119 -0
- package/src/lib/address-flow.ts +172 -0
- package/src/lib/address-helpers.ts +48 -0
- package/src/lib/address-line.ts +76 -0
- package/src/lib/address-pin-move.ts +97 -0
- package/src/lib/address-step-recovery.ts +63 -0
- package/src/lib/authed-image.ts +78 -0
- package/src/lib/biometric-auth.ts +52 -0
- package/src/lib/biometric-copy.ts +51 -0
- package/src/lib/captureRing.ts +83 -0
- package/src/lib/country-adoption.ts +89 -0
- package/src/lib/inferred-country.ts +65 -0
- package/src/lib/livenessLayout.ts +49 -0
- package/src/lib/map-frame.ts +159 -0
- package/src/lib/map-tiles.ts +143 -0
- package/src/lib/poa-country-gate.ts +27 -0
- package/src/lib/result-copy.ts +116 -0
- package/src/lib/result-wait.ts +53 -0
- package/src/lib/review-map-surface.ts +26 -0
- package/src/lib/scope.ts +31 -0
- package/src/lib/selfie-upload-wait.ts +79 -0
- package/src/lib/street-view-fov.ts +42 -0
- package/src/lib/webview-available.ts +47 -0
- package/src/liveness/useLiveness.ts +6 -1
- package/src/presence/background-math.ts +100 -0
- package/src/presence/background-store.ts +82 -0
- package/src/presence/background.ts +165 -0
- package/src/presence/foreground-service.ts +193 -0
- package/src/presence/fs.ts +51 -0
- package/src/presence/geofence.ts +41 -0
- package/src/presence/math.ts +44 -0
- package/src/presence/post.ts +40 -0
- package/src/presence/report.ts +87 -0
- package/src/presence/sampler.ts +110 -0
- package/src/presence/status.ts +92 -0
- package/src/presence/store.ts +94 -0
- package/src/presence/tier.ts +36 -0
- package/src/presence/watch-wait.ts +81 -0
- package/src/screens/AddressCountryControl.tsx +115 -0
- package/src/screens/BusinessDetailsFields.tsx +3 -0
- package/src/screens/BusinessDetailsStep.tsx +2 -0
- package/src/screens/BusinessDocumentSlot.tsx +3 -2
- package/src/screens/BusinessDocumentsStep.tsx +4 -55
- package/src/screens/CompanyInfoFields.tsx +6 -1
- package/src/screens/ConsentStep.tsx +6 -17
- package/src/screens/ContactActions.tsx +54 -0
- package/src/screens/ContactDestinationField.tsx +4 -0
- package/src/screens/ContactEntryPanel.tsx +75 -0
- package/src/screens/ContactVerificationStep.tsx +26 -56
- package/src/screens/CountrySelectStep.tsx +13 -147
- package/src/screens/LivenessAvatar.tsx +15 -3
- package/src/screens/LivenessStep.tsx +154 -52
- package/src/screens/PoaDocumentTypeList.tsx +66 -0
- package/src/screens/ProofOfAddressParts.tsx +151 -0
- package/src/screens/ProofOfAddressStep.tsx +58 -99
- package/src/screens/SubmittedBadge.tsx +25 -0
- package/src/screens/SubmittedError.tsx +64 -0
- package/src/screens/SubmittedResult.tsx +117 -0
- package/src/screens/SubmittedStep.tsx +71 -164
- package/src/screens/SubmittedSuccess.tsx +127 -0
- package/src/screens/SubmittedWaiting.tsx +45 -0
- package/src/screens/address/AddressEntranceStep.tsx +161 -0
- package/src/screens/address/AddressIntroGate.tsx +148 -0
- package/src/screens/address/AddressMapStub.tsx +50 -0
- package/src/screens/address/AddressPinStep.tsx +178 -0
- package/src/screens/address/AddressReviewStep.tsx +162 -0
- package/src/screens/address/AddressSandboxTabs.tsx +158 -0
- package/src/screens/address/AddressSearchField.tsx +115 -0
- package/src/screens/address/AddressSearchStep.tsx +94 -0
- package/src/screens/address/CurrentLocationRow.tsx +135 -0
- package/src/screens/address/DetailsSheet.tsx +175 -0
- package/src/screens/address/DetailsSheetFields.tsx +184 -0
- package/src/screens/address/EntranceDropzone.tsx +178 -0
- package/src/screens/address/EntranceFraming.tsx +66 -0
- package/src/screens/address/EntrancePills.tsx +60 -0
- package/src/screens/address/FramedStreetView.tsx +155 -0
- package/src/screens/address/IntroDisclosures.tsx +210 -0
- package/src/screens/address/LabelDecisionRow.tsx +113 -0
- package/src/screens/address/PinSummaryRow.tsx +105 -0
- package/src/screens/address/ReviewAddressBand.tsx +111 -0
- package/src/screens/address/ReviewEntranceThumbs.tsx +64 -0
- package/src/screens/address/ReviewMapPicture.tsx +106 -0
- package/src/screens/address/SearchResults.tsx +105 -0
- package/src/screens/address/SearchScreen.tsx +176 -0
- package/src/screens/address/SkipForNow.tsx +43 -0
- package/src/screens/address/StreetViewChrome.tsx +81 -0
- package/src/screens/address/detail-values.ts +22 -0
- package/src/screens/address/fix-source.ts +27 -0
- package/src/screens/address/index.ts +8 -0
- package/src/screens/address/meta.ts +44 -0
- package/src/screens/address/use-address-flow.ts +200 -0
- package/src/screens/address/use-label-pin.ts +80 -0
- package/src/screens/address/use-pin-actions.ts +192 -0
- package/src/screens/biometric/BiometricAuthFlow.tsx +170 -0
- package/src/screens/consent/model.ts +79 -10
- package/src/screens/liveness/CaptureRing.tsx +91 -0
- package/src/screens/liveness/LivenessCamera.tsx +63 -0
- package/src/screens/liveness/LivenessHandover.tsx +43 -0
- package/src/screens/liveness/LivenessOutcome.tsx +4 -3
- package/src/screens/liveness/SelfiePreview.tsx +13 -4
- package/src/screens/liveness/ShutterFlash.tsx +25 -0
- package/src/screens/liveness/index.ts +4 -0
- package/src/screens/liveness/useSelfieUpload.ts +23 -3
- package/src/screens/nfc/NfcScanIllustration.tsx +7 -18
- package/src/screens/useAddressPhotoAttach.ts +77 -0
- package/src/screens/useBusinessDocumentAttach.ts +93 -0
- package/src/screens/usePoaAttach.ts +12 -15
- package/src/services/api-biometric.ts +37 -0
- package/src/services/api-types-biometric.ts +41 -0
- package/src/services/api-types.ts +93 -1
- package/src/services/api-verify-types.ts +37 -0
- package/src/services/api.ts +133 -3
- package/src/services/deviceMetadata.ts +1 -1
- package/src/services/location.ts +234 -0
- package/src/services/uploadErrors.ts +30 -0
- package/src/services/workflowGate.ts +7 -0
- package/src/store/address.ts +97 -0
- package/src/store/derive.ts +29 -4
- package/src/store/kycStore.ts +112 -3
- package/src/store/serverConfig.ts +18 -0
- package/src/store/session.ts +23 -4
- package/src/store/state.ts +134 -0
- package/src/store/submit.ts +18 -1
- package/src/types/config.ts +48 -1
- package/src/types/verification.ts +12 -0
- 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
|
-
{
|
|
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
|
-
|
|
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
|
+
}
|
package/src/config/business.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|