@myazahq/kyc-sdk-react-native 2.4.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/build.gradle +35 -3
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +77 -0
- package/app.plugin.js +89 -8
- package/ios/HybridMyazaFaceDetector.swift +16 -3
- package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.cpp +23 -0
- package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.hpp +2 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaFaceDetectorSpec.kt +9 -0
- package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.cpp +8 -0
- package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.hpp +43 -0
- package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.hpp +17 -0
- package/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec.swift +2 -0
- package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec_cxx.swift +31 -0
- package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.cpp +2 -0
- package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.hpp +3 -0
- package/package.json +14 -2
- package/src/MyazaBiometricAuth.tsx +119 -0
- package/src/MyazaKYC.tsx +12 -0
- package/src/components/CountryField.tsx +8 -0
- package/src/components/CountryRegionPicker.tsx +184 -0
- package/src/components/DashedBorder.tsx +55 -0
- package/src/components/DialCodePicker.tsx +53 -67
- 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 -176
- 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/MultiIdProgress.tsx +107 -0
- package/src/components/MyazaInput.tsx +39 -4
- package/src/components/MyazaSelect.tsx +2 -1
- package/src/components/OptionRow.tsx +22 -3
- package/src/components/OwnershipSlider.tsx +102 -0
- package/src/components/PhoneNumberInput.tsx +45 -4
- package/src/components/PoweredBy.tsx +5 -5
- package/src/components/PresenceBlocks.tsx +153 -0
- package/src/components/StaggerIn.tsx +63 -0
- package/src/components/StepView.tsx +43 -0
- package/src/components/StickyActions.tsx +56 -0
- package/src/components/dialCodeRows.ts +64 -0
- package/src/components/glass/FloatingSheet.tsx +73 -21
- package/src/components/glass/GlassSheet.tsx +14 -2
- package/src/components/icon-map.ts +176 -0
- package/src/components/stepHeaderMeta.tsx +45 -5
- package/src/config/addressCollection.ts +125 -0
- package/src/config/biometricOptions.ts +100 -0
- package/src/config/business.ts +26 -7
- package/src/config/businessDetailsValidity.ts +52 -0
- package/src/config/businessPrefill.ts +72 -0
- package/src/config/businessSteps.ts +20 -3
- package/src/config/consentStep.ts +19 -0
- package/src/config/keyPeople.ts +111 -7
- package/src/config/keyPeopleCorporate.ts +19 -0
- package/src/config/keyPeopleOwnerTypes.ts +12 -0
- package/src/config/keyPeopleOwners.ts +26 -0
- package/src/config/keyPeoplePrefill.ts +138 -0
- package/src/config/keyPeopleSectionDefs.ts +65 -0
- package/src/config/keyPeopleSections.ts +167 -0
- package/src/config/proofOfAddress.ts +54 -11
- package/src/config/regions.ts +24 -0
- package/src/config/registrationHint.ts +1 -1
- package/src/config/stepOrder.ts +100 -6
- package/src/config/theme.ts +8 -2
- package/src/config/uploadLimits.ts +35 -0
- package/src/config/website.ts +36 -0
- package/src/config/workflowMerge.ts +16 -5
- package/src/emrtd/activeAuth.ts +100 -0
- package/src/emrtd/extras.ts +76 -0
- package/src/emrtd/files.ts +5 -0
- package/src/emrtd/index.ts +1 -0
- package/src/emrtd/read.ts +8 -1
- package/src/emrtd/session.ts +41 -0
- package/src/emrtd/stages.ts +11 -0
- 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/contact-recovery.ts +44 -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/multi-id.ts +210 -0
- package/src/lib/poa-country-gate.ts +27 -0
- package/src/lib/resubmit.ts +130 -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/screen-corners.ts +50 -0
- package/src/lib/selfie-upload-wait.ts +79 -0
- package/src/lib/step-log.ts +24 -3
- package/src/lib/street-view-fov.ts +42 -0
- package/src/lib/use-multi-id-plan.ts +29 -0
- package/src/lib/webview-available.ts +47 -0
- package/src/liveness/useLiveness.ts +6 -1
- package/src/liveness/useModelReady.ts +65 -0
- package/src/liveness/visionCameraFaceDetector.ts +35 -0
- 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/ApplicantRoleStep.tsx +9 -5
- package/src/screens/BusinessCheckPanel.tsx +89 -0
- package/src/screens/BusinessDetailsFields.tsx +112 -0
- package/src/screens/BusinessDetailsStep.tsx +133 -145
- package/src/screens/BusinessDocumentSlot.tsx +5 -5
- package/src/screens/BusinessDocumentsStep.tsx +5 -56
- package/src/screens/BusinessKeyPeopleStep.tsx +103 -71
- package/src/screens/BusinessPickedCard.tsx +86 -0
- package/src/screens/BusinessPickedSection.tsx +51 -0
- package/src/screens/BusinessRegistryPickers.tsx +67 -0
- package/src/screens/BusinessSandboxToggle.tsx +114 -0
- package/src/screens/BusinessSearch.tsx +199 -0
- package/src/screens/BusinessSearchResults.tsx +115 -0
- package/src/screens/CompanyInfoFields.tsx +91 -51
- package/src/screens/ConsentStep.tsx +37 -17
- package/src/screens/ContactActions.tsx +54 -0
- package/src/screens/ContactDestinationField.tsx +5 -0
- package/src/screens/ContactEntryPanel.tsx +75 -0
- package/src/screens/ContactVerificationStep.tsx +43 -42
- package/src/screens/CountrySelectStep.tsx +13 -147
- package/src/screens/IdTypeStep.tsx +12 -5
- package/src/screens/KeepLinksSheet.tsx +88 -0
- package/src/screens/KeyPeopleAwaitCard.tsx +204 -0
- package/src/screens/KeyPeopleAwaitList.tsx +126 -0
- package/src/screens/KeyPeoplePending.tsx +154 -0
- package/src/screens/KeyPeopleSection.tsx +162 -0
- package/src/screens/KeyPeopleSectionsList.tsx +103 -0
- package/src/screens/KeyPeopleUboExemption.tsx +72 -0
- package/src/screens/KeyPersonCard.tsx +36 -4
- package/src/screens/KeyPersonForm.tsx +195 -36
- package/src/screens/KeyPersonKindToggle.tsx +68 -0
- package/src/screens/KeyPersonOwners.tsx +109 -0
- package/src/screens/KeyPersonRoleChips.tsx +68 -0
- package/src/screens/KeyPersonSheet.tsx +92 -108
- package/src/screens/LivenessAvatar.tsx +15 -3
- package/src/screens/LivenessStep.tsx +172 -52
- package/src/screens/NfcStep.tsx +14 -0
- package/src/screens/PoaDocumentTypeList.tsx +66 -0
- package/src/screens/ProofOfAddressParts.tsx +151 -0
- package/src/screens/ProofOfAddressStep.tsx +58 -96
- 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 +82 -119
- 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/NfcReadProgress.tsx +1 -0
- package/src/screens/nfc/NfcScanIllustration.tsx +7 -18
- package/src/screens/useAddressPhotoAttach.ts +77 -0
- package/src/screens/useAwaitingPeople.ts +90 -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 +251 -3
- package/src/services/api-verify-types.ts +90 -1
- package/src/services/api.ts +237 -3
- package/src/services/deviceMetadata.ts +1 -1
- package/src/services/errors.ts +10 -2
- package/src/services/location.ts +234 -0
- package/src/services/uploadErrors.ts +30 -0
- package/src/services/workflowGate.ts +8 -0
- package/src/specs/MyazaFaceDetector.nitro.ts +29 -0
- package/src/store/address.ts +97 -0
- package/src/store/businessCheck.ts +113 -0
- package/src/store/derive.ts +44 -4
- package/src/store/kycStore.ts +352 -16
- package/src/store/serverConfig.ts +20 -0
- package/src/store/session.ts +227 -0
- package/src/store/state.ts +289 -2
- package/src/store/submit.ts +80 -13
- package/src/store/submitApplicant.ts +5 -11
- package/src/types/business.ts +23 -2
- package/src/types/config.ts +70 -2
- package/src/types/verification.ts +13 -1
- package/src/types/workflow.ts +112 -1
- package/src/screens/KeyPeopleInviteLinks.tsx +0 -239
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import type { KYCStep } from '../types/config';
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// The address flow as REAL steps: find it (search) → confirm it (pin, with the
|
|
5
|
+
// details sheet) → show it (entrance) → commit it (review + attest fix). The
|
|
6
|
+
// progress bar advances through them and back/forward is ordinary step
|
|
7
|
+
// navigation, not a machine hidden inside one screen. KYB keeps the single
|
|
8
|
+
// premises step — its pin + directions ARE the capture, and the KYB flow
|
|
9
|
+
// already has its own section rhythm.
|
|
10
|
+
//
|
|
11
|
+
// 'address-collection' is the PIN step and keeps the original wire name, so
|
|
12
|
+
// session progress saved by older builds restores cleanly and the server's
|
|
13
|
+
// step-log titles stay meaningful. Do not "fix" it to 'address-pin'.
|
|
14
|
+
//
|
|
15
|
+
// A MIRROR of the web SDK's steps/address/flow-steps.ts and the Flutter port —
|
|
16
|
+
// the same numbers and the same decisions, or one platform strands an
|
|
17
|
+
// applicant the others do not. Keep all three in lockstep.
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* SANDBOX mirrors the web SDK's stubbed vendor treatment (user decision
|
|
22
|
+
* 2026-09-03): placeholder map, canned labels, no search loads — the server's
|
|
23
|
+
* sandbox verdicts are canned anyway, so live vendor calls on test keys spend
|
|
24
|
+
* quota for nothing. DEVELOPMENT keeps the real surfaces (the platform's
|
|
25
|
+
* dev-is-real rule); an unknown environment counts as live.
|
|
26
|
+
*/
|
|
27
|
+
export function addressVendorsStubbed(facts: { environment?: string | null }): boolean {
|
|
28
|
+
return facts.environment === 'SANDBOX';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The canned pin label every stubbed surface shows — obviously a sample,
|
|
32
|
+
* never a real place. Keep in lockstep with the web SDK. */
|
|
33
|
+
export const SAMPLE_ADDRESS_LINE = '12 Sample Street, Sample City';
|
|
34
|
+
|
|
35
|
+
export interface AddressFlowOptions {
|
|
36
|
+
/** A search backend is available AND we are not in builder preview. */
|
|
37
|
+
searchAvailable: boolean;
|
|
38
|
+
/** The entrance-photo mode ('off' hides that capture). */
|
|
39
|
+
photoMode: 'off' | 'optional' | 'required';
|
|
40
|
+
/** Street View framing is offered (workflow on + a browser key in-document). */
|
|
41
|
+
streetViewOffered: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The individual flow's address steps, in order. */
|
|
45
|
+
export function addressFlowSteps(o: AddressFlowOptions): KYCStep[] {
|
|
46
|
+
const steps: KYCStep[] = [];
|
|
47
|
+
if (o.searchAvailable) steps.push('address-search');
|
|
48
|
+
steps.push('address-collection');
|
|
49
|
+
if (o.photoMode !== 'off' || o.streetViewOffered) steps.push('address-entrance');
|
|
50
|
+
steps.push('address-review');
|
|
51
|
+
return steps;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const ADDRESS_STEPS: ReadonlySet<KYCStep> = new Set<KYCStep>([
|
|
55
|
+
'address-search',
|
|
56
|
+
'address-collection',
|
|
57
|
+
'address-entrance',
|
|
58
|
+
'address-review',
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
/** Whether `step` belongs to the address flow. */
|
|
62
|
+
export function isAddressStep(step: KYCStep): boolean {
|
|
63
|
+
return ADDRESS_STEPS.has(step);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function nextAddressStep(steps: KYCStep[], current: KYCStep): KYCStep | null {
|
|
67
|
+
const i = steps.indexOf(current);
|
|
68
|
+
return i >= 0 && i + 1 < steps.length ? steps[i + 1]! : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function prevAddressStep(steps: KYCStep[], current: KYCStep): KYCStep | null {
|
|
72
|
+
const i = steps.indexOf(current);
|
|
73
|
+
return i > 0 ? steps[i - 1]! : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Derive the flow options from raw config facts — ONE place, read by the step
|
|
78
|
+
* hook AND the flow-order builder, so the two can never disagree about which
|
|
79
|
+
* screens exist. A mirror of the web SDK's addressFlowOptions.
|
|
80
|
+
*
|
|
81
|
+
* `hasGoogleKey` is always false on mobile (the key describes an in-document
|
|
82
|
+
* browser surface). Street View reaches a phone the same way the framed map
|
|
83
|
+
* does: the hosted /embed/street-view page in a WebView on the app grant,
|
|
84
|
+
* which is `hasStreetViewFrame` — the server minted a maps frame URL AND
|
|
85
|
+
* `react-native-webview` is installed. Absent either, the entrance step is
|
|
86
|
+
* photo-only, exactly as it was before 2026-09-06.
|
|
87
|
+
*/
|
|
88
|
+
export function addressFlowOptions(facts: {
|
|
89
|
+
photo?: 'off' | 'optional' | 'required';
|
|
90
|
+
streetView?: 'off' | 'optional' | 'required';
|
|
91
|
+
serverSearch: boolean;
|
|
92
|
+
previewMode: boolean;
|
|
93
|
+
hasGoogleKey: boolean;
|
|
94
|
+
/** A maps frame URL this install can render: the framed
|
|
95
|
+
* /embed/street-view page carries the panorama for it. */
|
|
96
|
+
hasStreetViewFrame: boolean;
|
|
97
|
+
}): AddressFlowOptions {
|
|
98
|
+
const photoMode = facts.photo ?? 'optional';
|
|
99
|
+
return {
|
|
100
|
+
searchAvailable: facts.serverSearch && !facts.previewMode,
|
|
101
|
+
photoMode,
|
|
102
|
+
// ON by default: offered unless the workflow opted out, and wherever a
|
|
103
|
+
// Google surface exists.
|
|
104
|
+
streetViewOffered:
|
|
105
|
+
facts.streetView !== 'off' && (facts.hasGoogleKey || facts.hasStreetViewFrame),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Great-circle metres between two points (small-distance haversine). */
|
|
110
|
+
export function metersBetween(
|
|
111
|
+
a: { lat: number; lng: number },
|
|
112
|
+
b: { lat: number; lng: number },
|
|
113
|
+
): number {
|
|
114
|
+
const R = 6371000;
|
|
115
|
+
const toRad = (d: number) => (d * Math.PI) / 180;
|
|
116
|
+
const dLat = toRad(b.lat - a.lat);
|
|
117
|
+
const dLng = toRad(b.lng - a.lng);
|
|
118
|
+
const h =
|
|
119
|
+
Math.sin(dLat / 2) ** 2 +
|
|
120
|
+
Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLng / 2) ** 2;
|
|
121
|
+
return 2 * R * Math.asin(Math.sqrt(h));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* A map settle within about a metre of the current pin is the tile roundtrip
|
|
126
|
+
* drifting, not a move. Acting on it rebuilt the address WITHOUT its label, and
|
|
127
|
+
* the reverse geocoder then overwrote a searched-and-picked address with the
|
|
128
|
+
* area line. BOTH axes must be inside the epsilon for a move to be ignored.
|
|
129
|
+
*/
|
|
130
|
+
export const PIN_EPSILON = 1e-5;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* How far a pin may move from the spot a label was PICKED for before the label
|
|
134
|
+
* stops credibly naming it. The pick names the property; the nudge refines
|
|
135
|
+
* where its roof is — same scale as the server's at-address tolerance. Beyond
|
|
136
|
+
* this, keeping the picked name would be a lie.
|
|
137
|
+
*/
|
|
138
|
+
export const KEEP_PICKED_LABEL_RADIUS_M = 250;
|
|
139
|
+
|
|
140
|
+
/** Below this, a pin move is roof refinement — keep the picked label silently;
|
|
141
|
+
* asking would be noise. */
|
|
142
|
+
export const LABEL_PROMPT_MIN_MOVE_M = 25;
|
|
143
|
+
|
|
144
|
+
/** Debounce before reverse-geocoding a moved pin. */
|
|
145
|
+
export const REVERSE_DEBOUNCE_MS = 700;
|
|
146
|
+
|
|
147
|
+
/** Places autocomplete: debounce, and the floor below which nothing is asked. */
|
|
148
|
+
export const AUTOCOMPLETE_DEBOUNCE_MS = 300;
|
|
149
|
+
export const SEARCH_MIN_QUERY_LENGTH = 3;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Whether the pin screen should ASK "keep the selected address?" — the
|
|
153
|
+
* applicant decides the label's fate, never a silent discard. Ask once past the
|
|
154
|
+
* refinement threshold; a prior "keep" stands until the pin crosses the
|
|
155
|
+
* credibility radius, where setPin resets it so the question returns exactly
|
|
156
|
+
* once. A DERIVED label (no pickedAt anchor) is never questioned: those
|
|
157
|
+
* re-derive freely on every move.
|
|
158
|
+
*/
|
|
159
|
+
export function shouldAskLabelDecision(address: {
|
|
160
|
+
lat: number;
|
|
161
|
+
lng: number;
|
|
162
|
+
label?: string;
|
|
163
|
+
pickedAt?: { lat: number; lng: number };
|
|
164
|
+
labelKept?: boolean;
|
|
165
|
+
}): boolean {
|
|
166
|
+
if (!address.pickedAt || !address.label || address.labelKept) return false;
|
|
167
|
+
return metersBetween(address.pickedAt, address) > LABEL_PROMPT_MIN_MOVE_M;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// The displayed address line lives next door (200-line rule), re-exported so
|
|
171
|
+
// every importer of this file keeps working.
|
|
172
|
+
export { displayAddressLine } from './address-line';
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Address state shaping — the pure half of "the pin moved".
|
|
3
|
+
//
|
|
4
|
+
// Split from address-flow.ts (the step model + the label rules) per the
|
|
5
|
+
// 200-line rule, and a MIRROR of the web SDK's steps/address-helpers.ts.
|
|
6
|
+
//
|
|
7
|
+
// The device fixes themselves live in services/location.ts, which owns the
|
|
8
|
+
// expo-location calls; nothing here touches hardware.
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The address state after picking a search candidate: the pin lands on the
|
|
13
|
+
* hit, the applicant's typed fields survive, and the house number prefills
|
|
14
|
+
* ONLY when they have not typed one (their word always beats the map's).
|
|
15
|
+
*
|
|
16
|
+
* The shape is FIXED on purpose. Spreading it as the new address DROPS
|
|
17
|
+
* `label`, `parts`, `pickedAt`, `labelKept`, `streetView` and the device
|
|
18
|
+
* fields, so a caller that wants one of those back has to re-add it
|
|
19
|
+
* explicitly rather than carrying a stale label to a new spot by accident.
|
|
20
|
+
*/
|
|
21
|
+
export function pickedAddressState(
|
|
22
|
+
prev:
|
|
23
|
+
| { directions?: string; propertyName?: string; propertyNumber?: string; street?: string }
|
|
24
|
+
| null
|
|
25
|
+
| undefined,
|
|
26
|
+
hit: { lat: number; lng: number; houseNumber: string | null },
|
|
27
|
+
): {
|
|
28
|
+
lat: number;
|
|
29
|
+
lng: number;
|
|
30
|
+
accuracy: null;
|
|
31
|
+
directions: string;
|
|
32
|
+
propertyName: string;
|
|
33
|
+
propertyNumber: string;
|
|
34
|
+
street: string | undefined;
|
|
35
|
+
} {
|
|
36
|
+
return {
|
|
37
|
+
lat: hit.lat,
|
|
38
|
+
lng: hit.lng,
|
|
39
|
+
accuracy: null,
|
|
40
|
+
directions: prev?.directions ?? '',
|
|
41
|
+
propertyName: prev?.propertyName ?? '',
|
|
42
|
+
propertyNumber: prev?.propertyNumber?.trim() ? prev.propertyNumber : (hit.houseNumber ?? ''),
|
|
43
|
+
// Undefined, not '': the details sheet reads '' as "deliberately cleared"
|
|
44
|
+
// and undefined as "never touched" — initialising with '' suppressed the
|
|
45
|
+
// resolved-street prefill forever.
|
|
46
|
+
street: prev?.street,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// The address line the flow SHOWS. Split from address-flow.ts (200-line
|
|
2
|
+
// rule); a mirror of the web SDK's displayAddressLine and the Flutter
|
|
3
|
+
// address_flow.dart twin. Keep the three in lockstep.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The address line the flow SHOWS (pin summary + review heading) — the client
|
|
7
|
+
* mirror of the server's composed-line rules, so what the applicant confirms is
|
|
8
|
+
* what the org later reads. A typed number REPLACES a differing picked number
|
|
9
|
+
* (living at 8 when only 11 was listed is not "8, 11 Bassey Street"); a typed
|
|
10
|
+
* street the label does not carry leads the line.
|
|
11
|
+
*/
|
|
12
|
+
export function displayAddressLine(address: {
|
|
13
|
+
lat: number;
|
|
14
|
+
lng: number;
|
|
15
|
+
label?: string | null;
|
|
16
|
+
propertyNumber?: string | null;
|
|
17
|
+
street?: string | null;
|
|
18
|
+
unit?: string | null;
|
|
19
|
+
neighbourhood?: string | null;
|
|
20
|
+
city?: string | null;
|
|
21
|
+
state?: string | null;
|
|
22
|
+
postcode?: string | null;
|
|
23
|
+
}): string {
|
|
24
|
+
const number = address.propertyNumber?.trim() || null;
|
|
25
|
+
const typed = address.street?.trim() || null;
|
|
26
|
+
const label = address.label?.trim() || null;
|
|
27
|
+
const unit = address.unit?.trim() || null;
|
|
28
|
+
// Edit-details corrections ride the tail through a part-wise dedupe,
|
|
29
|
+
// mirroring the server's composed line: identical values vanish, a
|
|
30
|
+
// correction appends beside the map's own answer.
|
|
31
|
+
const withClaims = (parts: string[]): string => {
|
|
32
|
+
const out = unit ? [unit, ...parts] : [...parts];
|
|
33
|
+
const seen = (v: string) => out.some((p) => p.toLowerCase() === v.toLowerCase());
|
|
34
|
+
for (const claim of [address.neighbourhood, address.city, address.state, address.postcode]) {
|
|
35
|
+
const t = claim?.trim();
|
|
36
|
+
if (t && !seen(t)) out.push(t);
|
|
37
|
+
}
|
|
38
|
+
return out.join(', ');
|
|
39
|
+
};
|
|
40
|
+
if (!label) {
|
|
41
|
+
if (typed) return withClaims([number ? `${number} ${typed}` : typed]);
|
|
42
|
+
const claimed = withClaims([]);
|
|
43
|
+
if (claimed) return claimed;
|
|
44
|
+
// NEVER coordinates. A moved pin has no line until the reverse geocode
|
|
45
|
+
// answers, and "4.93240, 8.32540" is not an address: it read as one, for
|
|
46
|
+
// the second or so before the real line arrived. Empty means "nothing
|
|
47
|
+
// human-readable yet", and the caller shows that it is still coming.
|
|
48
|
+
return '';
|
|
49
|
+
}
|
|
50
|
+
const segs = label
|
|
51
|
+
.split(', ')
|
|
52
|
+
.map((t) => t.trim())
|
|
53
|
+
.filter(Boolean);
|
|
54
|
+
if (typed && !label.toLowerCase().includes(typed.toLowerCase())) {
|
|
55
|
+
return withClaims([number ? `${number} ${typed}` : typed, ...segs]);
|
|
56
|
+
}
|
|
57
|
+
if (number) {
|
|
58
|
+
const first = segs[0] ?? '';
|
|
59
|
+
const firstTokens = first.toLowerCase().split(/\s+/);
|
|
60
|
+
const leading = firstTokens[0] ?? '';
|
|
61
|
+
if (/^\d+[a-z]?$/i.test(leading) && leading !== number.toLowerCase()) {
|
|
62
|
+
segs[0] = [number, ...first.split(/\s+/).slice(1)].join(' ');
|
|
63
|
+
} else if (!firstTokens.includes(number.toLowerCase())) {
|
|
64
|
+
return withClaims([number, ...segs]);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return withClaims(segs);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Read to assistive tech while the reverse geocode is out: the pin has a
|
|
71
|
+
* line coming, and a lat/lng pair is not it. Sighted users see a skeleton
|
|
72
|
+
* line in its place (components/LineSkeleton), never a spinner. Mirrored on
|
|
73
|
+
* the web and Flutter SDKs. */
|
|
74
|
+
export const ADDRESS_LINE_PENDING = 'Finding the address…';
|
|
75
|
+
/** Shown when the geocode came back with nothing. Still not coordinates. */
|
|
76
|
+
export const ADDRESS_LINE_UNAVAILABLE = 'No address found for this spot';
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { pickedAddressState } from './address-helpers';
|
|
2
|
+
import { KEEP_PICKED_LABEL_RADIUS_M, PIN_EPSILON, metersBetween } from './address-flow';
|
|
3
|
+
import type { AddressState } from '../store/state';
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// What the address becomes when the pin moves.
|
|
7
|
+
//
|
|
8
|
+
// Pure, so the rules that decide whether a human-confirmed label survives can
|
|
9
|
+
// be read and tested without a map, a store, or a GPS. The hook does the
|
|
10
|
+
// dispatching; these decide.
|
|
11
|
+
//
|
|
12
|
+
// A MIRROR of the web SDK's setPin / applyCurrentFix / adoptPinAddress bodies.
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
/** Where a pin move lands. `ignore` means the map settled, it did not move. */
|
|
16
|
+
export type PinMove =
|
|
17
|
+
| { kind: 'ignore' }
|
|
18
|
+
/** The picked label stands: only the coordinates (and the keep flag) change,
|
|
19
|
+
* and NO reverse geocode is wanted. */
|
|
20
|
+
| { kind: 'keep-label'; address: AddressState }
|
|
21
|
+
/** The address is rebuilt around the new spot and wants a fresh label. */
|
|
22
|
+
| { kind: 'rebuild'; address: AddressState };
|
|
23
|
+
|
|
24
|
+
export function pinMove(
|
|
25
|
+
cur: AddressState | null,
|
|
26
|
+
next: { lat: number; lng: number },
|
|
27
|
+
accuracy: number | null,
|
|
28
|
+
): PinMove {
|
|
29
|
+
// A map settle within about a metre of the current pin is the tile roundtrip
|
|
30
|
+
// drifting, not a move. Acting on it rebuilt the address WITHOUT its label,
|
|
31
|
+
// and the reverse geocoder then overwrote a searched-and-picked address with
|
|
32
|
+
// the area line. BOTH axes must be inside the epsilon.
|
|
33
|
+
if (
|
|
34
|
+
cur &&
|
|
35
|
+
Math.abs(cur.lat - next.lat) < PIN_EPSILON &&
|
|
36
|
+
Math.abs(cur.lng - next.lng) < PIN_EPSILON
|
|
37
|
+
) {
|
|
38
|
+
return { kind: 'ignore' };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// A PICKED label names the property; the pin refines where its roof is, so
|
|
42
|
+
// the label survives the move and the applicant decides its fate. Crossing
|
|
43
|
+
// the credibility radius only resets a prior "keep", so the question is
|
|
44
|
+
// asked again exactly once out there.
|
|
45
|
+
if (cur?.pickedAt && cur.label) {
|
|
46
|
+
const beyond = metersBetween(cur.pickedAt, next) > KEEP_PICKED_LABEL_RADIUS_M;
|
|
47
|
+
return {
|
|
48
|
+
kind: 'keep-label',
|
|
49
|
+
address: {
|
|
50
|
+
...cur,
|
|
51
|
+
lat: next.lat,
|
|
52
|
+
lng: next.lng,
|
|
53
|
+
accuracy,
|
|
54
|
+
...(beyond && cur.labelKept ? { labelKept: false } : {}),
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Typed fields AND a captured Street View frame survive a re-pin; the
|
|
60
|
+
// coordinates always win. A DERIVED label dies with the old spot.
|
|
61
|
+
return {
|
|
62
|
+
kind: 'rebuild',
|
|
63
|
+
address: {
|
|
64
|
+
...pickedAddressState(cur, { lat: next.lat, lng: next.lng, houseNumber: null }),
|
|
65
|
+
accuracy,
|
|
66
|
+
...(cur?.streetView ? { streetView: cur.streetView } : {}),
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The address after landing on the device's current fix — the bootstrap
|
|
73
|
+
* override, used while no address exists yet.
|
|
74
|
+
*
|
|
75
|
+
* It sets NO `pickedAt`, so the resulting label counts as DERIVED and
|
|
76
|
+
* re-derives freely on the next move. A fix is where the phone is, not a
|
|
77
|
+
* property the applicant named.
|
|
78
|
+
*/
|
|
79
|
+
export function fixApplied(
|
|
80
|
+
cur: AddressState | null,
|
|
81
|
+
fix: { lat: number; lng: number; accuracy: number | null; label: string | null; parts?: unknown },
|
|
82
|
+
): AddressState {
|
|
83
|
+
return {
|
|
84
|
+
...pickedAddressState(cur, { lat: fix.lat, lng: fix.lng, houseNumber: null }),
|
|
85
|
+
accuracy: fix.accuracy,
|
|
86
|
+
...(cur?.streetView ? { streetView: cur.streetView } : {}),
|
|
87
|
+
...(fix.label ? { label: fix.label } : {}),
|
|
88
|
+
...(fix.parts ? { parts: fix.parts as AddressState['parts'] } : {}),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The address with the human-confirmed label dropped, ready to be relabelled
|
|
93
|
+
* from the pin. */
|
|
94
|
+
export function withoutPickedLabel(cur: AddressState): AddressState {
|
|
95
|
+
const { label: _l, parts: _p, pickedAt: _a, labelKept: _k, ...rest } = cur;
|
|
96
|
+
return rest;
|
|
97
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { isAddressStep } from './address-flow';
|
|
2
|
+
import type { KYCStep } from '../types/config';
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Recovering an attempt resumed onto an address step the flow no longer has.
|
|
6
|
+
//
|
|
7
|
+
// The address flow is the only part of the SDK whose SHAPE depends on something
|
|
8
|
+
// other than the workflow the applicant is walking: `searchAvailable` comes from
|
|
9
|
+
// a server flag that arrives after the session restore has already written the
|
|
10
|
+
// step it was saved on. Add a republish that drops the entrance photo, and a
|
|
11
|
+
// resumed applicant can be sitting on a screen the current order does not
|
|
12
|
+
// contain — where Continue has nothing to advance to and Back has no
|
|
13
|
+
// predecessor, so the flow is simply stuck.
|
|
14
|
+
//
|
|
15
|
+
// The rule is FORWARD: a step the flow no longer has is a capture it is no
|
|
16
|
+
// longer asking for, so the applicant carries on rather than being sent back to
|
|
17
|
+
// redo work they had already finished.
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/** The address flow's four screens, in the order they are always walked. */
|
|
21
|
+
const CANONICAL: readonly KYCStep[] = [
|
|
22
|
+
'address-search',
|
|
23
|
+
'address-collection',
|
|
24
|
+
'address-entrance',
|
|
25
|
+
'address-review',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The step that follows the address region in an order that CONTAINS it — where
|
|
30
|
+
* an applicant belongs when the workflow has dropped address collection
|
|
31
|
+
* altogether. Computed from the real order rather than guessed, so it is the
|
|
32
|
+
* step they would have reached had they finished.
|
|
33
|
+
*/
|
|
34
|
+
export function addressExitStep(orderWithAddress: readonly KYCStep[]): KYCStep | null {
|
|
35
|
+
for (let i = orderWithAddress.length - 1; i >= 0; i -= 1) {
|
|
36
|
+
if (isAddressStep(orderWithAddress[i]!)) return orderWithAddress[i + 1] ?? null;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Where to send an applicant sitting on an address step this flow does not
|
|
43
|
+
* contain, or null when there is nothing wrong.
|
|
44
|
+
*
|
|
45
|
+
* `inFlow` is the address steps the CURRENT order has, in order; `exit` is where
|
|
46
|
+
* to go when it has none left (see `addressExitStep`).
|
|
47
|
+
*/
|
|
48
|
+
export function recoverAddressStep(
|
|
49
|
+
current: KYCStep,
|
|
50
|
+
inFlow: readonly KYCStep[],
|
|
51
|
+
exit: KYCStep | null,
|
|
52
|
+
): KYCStep | null {
|
|
53
|
+
if (!isAddressStep(current) || inFlow.includes(current)) return null;
|
|
54
|
+
const from = CANONICAL.indexOf(current);
|
|
55
|
+
for (let i = from + 1; i < CANONICAL.length; i += 1) {
|
|
56
|
+
const step = CANONICAL[i]!;
|
|
57
|
+
if (inFlow.includes(step)) return step;
|
|
58
|
+
}
|
|
59
|
+
// Nothing after it either: the last screen the flow does have (KYB resumed
|
|
60
|
+
// onto a review it never renders belongs on its premises pin), else out of
|
|
61
|
+
// the address flow entirely.
|
|
62
|
+
return inFlow[inFlow.length - 1] ?? exit;
|
|
63
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
// ─── Server-side pictures, fetched with the SDK's own bearer ────────────────
|
|
4
|
+
//
|
|
5
|
+
// The review card's two pictures (the static map, the framed Street View)
|
|
6
|
+
// come through OUR server, so the request has to carry the SDK's bearer.
|
|
7
|
+
// React Native's <Image> accepts `source.headers` for exactly this and honours
|
|
8
|
+
// it on iOS; on Android (RN 0.85, new architecture) the header never leaves
|
|
9
|
+
// the device: the server answered 401 to every thumbnail request and the card
|
|
10
|
+
// drew an empty bordered box where the entrance should have been (Galaxy S24,
|
|
11
|
+
// 2026-09-07). So the bytes are fetched with the same `fetch` the API client
|
|
12
|
+
// uses, which carries headers on both platforms, and <Image> is handed a data
|
|
13
|
+
// URI it cannot get wrong. The bytes are base64-encoded here rather than via
|
|
14
|
+
// Blob + FileReader, which RN's fetch cannot supply (see bytesToBase64).
|
|
15
|
+
|
|
16
|
+
export interface AuthedImageSource {
|
|
17
|
+
uri: string;
|
|
18
|
+
headers: Record<string, string>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
22
|
+
|
|
23
|
+
/** Plain base64, in JS. React Native's `Response.blob()` cannot build a Blob
|
|
24
|
+
* from the ArrayBuffer its own fetch holds ("Creating blobs from
|
|
25
|
+
* 'ArrayBuffer' … are not supported"), so Blob and FileReader are out; a
|
|
26
|
+
* thumbnail is tens of kilobytes and this loop is nothing. */
|
|
27
|
+
export function bytesToBase64(bytes: Uint8Array): string {
|
|
28
|
+
let out = '';
|
|
29
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
30
|
+
const a = bytes[i] ?? 0;
|
|
31
|
+
const b = bytes[i + 1] ?? 0;
|
|
32
|
+
const c = bytes[i + 2] ?? 0;
|
|
33
|
+
const triple = (a << 16) | (b << 8) | c;
|
|
34
|
+
out += B64.charAt((triple >> 18) & 63) + B64.charAt((triple >> 12) & 63);
|
|
35
|
+
out += i + 1 < bytes.length ? B64.charAt((triple >> 6) & 63) : '=';
|
|
36
|
+
out += i + 2 < bytes.length ? B64.charAt(triple & 63) : '=';
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function fetchImageDataUri(source: AuthedImageSource, signal?: AbortSignal): Promise<string> {
|
|
42
|
+
const res = await fetch(source.uri, { headers: source.headers, signal });
|
|
43
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
44
|
+
const mime = res.headers.get('content-type')?.split(';')[0]?.trim() || 'image/jpeg';
|
|
45
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
46
|
+
return `data:${mime};base64,${bytesToBase64(bytes)}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface AuthedImage {
|
|
50
|
+
/** The picture as a data URI once it has arrived. */
|
|
51
|
+
uri: string | null;
|
|
52
|
+
/** It never will: the request was refused or the bytes were unreadable. */
|
|
53
|
+
failed: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const IDLE: AuthedImage = { uri: null, failed: false };
|
|
57
|
+
|
|
58
|
+
/** One server picture, keyed on its URL; `label` names it in the dev log. */
|
|
59
|
+
export function useAuthedImage(source: AuthedImageSource | null, label: string): AuthedImage {
|
|
60
|
+
const [state, setState] = useState<AuthedImage>(IDLE);
|
|
61
|
+
const key = source?.uri ?? null;
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
setState(IDLE);
|
|
64
|
+
if (!source) return undefined;
|
|
65
|
+
const controller = new AbortController();
|
|
66
|
+
fetchImageDataUri(source, controller.signal)
|
|
67
|
+
.then((uri) => setState({ uri, failed: false }))
|
|
68
|
+
.catch((err: unknown) => {
|
|
69
|
+
if (controller.signal.aborted) return;
|
|
70
|
+
if (__DEV__) console.warn(`${label} failed to load`, err);
|
|
71
|
+
setState({ uri: null, failed: true });
|
|
72
|
+
});
|
|
73
|
+
return () => controller.abort();
|
|
74
|
+
// The headers are the SDK's own and never change; the URL is the picture.
|
|
75
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
76
|
+
}, [key]);
|
|
77
|
+
return state;
|
|
78
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { KYCApiError } from '../services/api';
|
|
2
|
+
import { KYCError } from '../types/verification';
|
|
3
|
+
import type { BiometricAuthResponse } from '../services/api-types-biometric';
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Biometric re-authentication — the pure half. Mirrors the web SDK's
|
|
7
|
+
// MyazaBiometricAuth error mapping and outcome shapes (keep in lockstep).
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
export type BiometricAuthOutcome =
|
|
11
|
+
| { kind: 'success'; result: BiometricAuthResponse }
|
|
12
|
+
| { kind: 'failed'; result: BiometricAuthResponse }
|
|
13
|
+
| { kind: 'error'; message: string };
|
|
14
|
+
|
|
15
|
+
export interface BiometricAuthenticated {
|
|
16
|
+
attemptId: string;
|
|
17
|
+
confidence: number | null;
|
|
18
|
+
/** Single-use proof; verify from your backend at `/biometric/verify-proof`. */
|
|
19
|
+
token?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface BiometricAuthFailed {
|
|
23
|
+
status: BiometricAuthResponse['status'];
|
|
24
|
+
attemptId: string;
|
|
25
|
+
confidence: number | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A technical failure, in the SDK's typed vocabulary + a message a person
|
|
29
|
+
* can read. Never the server's own wording — that is an internal contract. */
|
|
30
|
+
export function mapAuthError(err: unknown): KYCError {
|
|
31
|
+
if (err instanceof KYCApiError) {
|
|
32
|
+
if (err.statusCode === 404 && err.code === 'not_enrolled') {
|
|
33
|
+
return new KYCError('unknown', "You're not set up for face verification yet.");
|
|
34
|
+
}
|
|
35
|
+
if (err.statusCode === 402) {
|
|
36
|
+
return new KYCError('insufficient_credits', 'Face verification is temporarily unavailable.');
|
|
37
|
+
}
|
|
38
|
+
if (err.statusCode === 401 || err.statusCode === 403) {
|
|
39
|
+
return new KYCError('invalid_api_key', 'This app is not authorised for face verification.');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return new KYCError('network_error', 'Something went wrong. Please try again.');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The verdict as the callbacks and the result screen read it. */
|
|
46
|
+
export function outcomeOf(result: BiometricAuthResponse): BiometricAuthOutcome {
|
|
47
|
+
return result.authenticated ? { kind: 'success', result } : { kind: 'failed', result };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function defaultReauthLabel(companyName?: string): string {
|
|
51
|
+
return companyName ? `Verify it's you with ${companyName}` : "Verify it's you";
|
|
52
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { configScope } from './scope';
|
|
2
|
+
import { fillTokens } from '../utils/tokens';
|
|
3
|
+
import type { BiometricCopy, BiometricCopyText } from '../config/biometricOptions';
|
|
4
|
+
import type { MyazaKYCConfig } from '../types/config';
|
|
5
|
+
|
|
6
|
+
// ─── The org's copy on the biometric screens, ready to render ───────────────
|
|
7
|
+
//
|
|
8
|
+
// Pure. Resolves `config.biometric.copy` for the screens: tokens filled from
|
|
9
|
+
// `userData` exactly as the consent and success copy are, a field that empties
|
|
10
|
+
// out once its tokens resolve treated as absent (the default shows rather than
|
|
11
|
+
// a blank title), and everything null off the biometric scopes, where the
|
|
12
|
+
// block never applies. The screens hand the result to describeWaiting /
|
|
13
|
+
// describeOutcome (lib/result-copy.ts) as overrides. Mirrors the web SDK's
|
|
14
|
+
// lib/biometric-copy.ts and Flutter's config/biometric_copy.dart; keep the
|
|
15
|
+
// three in lockstep.
|
|
16
|
+
|
|
17
|
+
export interface ResolvedBiometricCopy {
|
|
18
|
+
waiting: BiometricCopyText | null;
|
|
19
|
+
verified: BiometricCopyText | null;
|
|
20
|
+
declined: BiometricCopyText | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const NO_BIOMETRIC_COPY: ResolvedBiometricCopy = { waiting: null, verified: null, declined: null };
|
|
24
|
+
|
|
25
|
+
type CopyConfigLike = {
|
|
26
|
+
scope?: string;
|
|
27
|
+
biometric?: { copy?: BiometricCopy } | null;
|
|
28
|
+
userData?: MyazaKYCConfig['userData'];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function biometricCopyFor(config: CopyConfigLike): ResolvedBiometricCopy {
|
|
32
|
+
const scope = configScope(config);
|
|
33
|
+
if (scope !== 'biometric-authentication' && scope !== 'biometric-enrollment') return NO_BIOMETRIC_COPY;
|
|
34
|
+
const copy = config.biometric?.copy;
|
|
35
|
+
if (!copy) return NO_BIOMETRIC_COPY;
|
|
36
|
+
const fill = (text: BiometricCopyText | undefined): BiometricCopyText | null => {
|
|
37
|
+
if (!text) return null;
|
|
38
|
+
const title = text.title ? fillTokens(text.title, config.userData) : '';
|
|
39
|
+
const description = text.description ? fillTokens(text.description, config.userData) : '';
|
|
40
|
+
const out: BiometricCopyText = {};
|
|
41
|
+
if (title) out.title = title;
|
|
42
|
+
if (description) out.description = description;
|
|
43
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
44
|
+
};
|
|
45
|
+
return {
|
|
46
|
+
waiting: fill(copy.waiting),
|
|
47
|
+
// Enrolment shows no verdict, so a stray key there has no screen to land on.
|
|
48
|
+
verified: scope === 'biometric-authentication' ? fill(copy.verified) : null,
|
|
49
|
+
declined: scope === 'biometric-authentication' ? fill(copy.declined) : null,
|
|
50
|
+
};
|
|
51
|
+
}
|