@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,110 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Pure state machine for the foreground-service tier. A geofence hands us
|
|
3
|
+
// ENTER/EXIT moments; a periodic location sample hands us POSITIONS, and the
|
|
4
|
+
// job here is to turn positions back into the same enter/exit spans the
|
|
5
|
+
// geofence tier folds, on the SAME stored `enterAt`, so the two tiers
|
|
6
|
+
// cooperate on one state rather than double-counting a stay:
|
|
7
|
+
//
|
|
8
|
+
// inside, no open stay → open one (stamp enterAt)
|
|
9
|
+
// inside, open stay → nothing (the fence or an earlier sample did it)
|
|
10
|
+
// outside, open stay → close it: fold the span into per-day aggregates
|
|
11
|
+
// outside, no open stay → nothing (absence is never evidence)
|
|
12
|
+
// mocked → report the day FLAGGED, never open a stay
|
|
13
|
+
//
|
|
14
|
+
// Why this exists at all: on phones whose manufacturers kill background work
|
|
15
|
+
// (the Tecno/Infinix/Xiaomi population that dominates our markets), geofence
|
|
16
|
+
// transitions are dropped, silently. A foreground service keeps the process
|
|
17
|
+
// alive and hands it fixes every few minutes; folding those through the same
|
|
18
|
+
// span logic is what makes verification finish on those devices.
|
|
19
|
+
//
|
|
20
|
+
// Mirrors the Flutter plugin's PresenceSampler.kt — keep the two in lockstep.
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
import { foldSpanIntoDays } from './background-math';
|
|
24
|
+
import { insideFence, localDayAndNight } from './math';
|
|
25
|
+
import type { WireObservation } from './post';
|
|
26
|
+
|
|
27
|
+
export interface SampleFix {
|
|
28
|
+
lat: number;
|
|
29
|
+
lng: number;
|
|
30
|
+
accuracy: number | null;
|
|
31
|
+
timestamp: number;
|
|
32
|
+
mocked: boolean | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SamplerResult {
|
|
36
|
+
/** The open stay after these samples (null = none open). */
|
|
37
|
+
enterAt: number | null;
|
|
38
|
+
/** Per-day aggregates to queue (same-day entries already merged). */
|
|
39
|
+
observations: WireObservation[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Merge day aggregates that landed on the same day (several spans a day):
|
|
43
|
+
* sum dwell, OR night, sum samples. The geofence flush uses the same rule. */
|
|
44
|
+
export function mergeObservations(
|
|
45
|
+
existing: WireObservation[],
|
|
46
|
+
fresh: WireObservation[],
|
|
47
|
+
): WireObservation[] {
|
|
48
|
+
const byDay = new Map(existing.map((o) => [o.day, { ...o }]));
|
|
49
|
+
for (const o of fresh) {
|
|
50
|
+
const prior = byDay.get(o.day);
|
|
51
|
+
if (!prior) {
|
|
52
|
+
byDay.set(o.day, { ...o });
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
prior.dwellMinutes += o.dwellMinutes;
|
|
56
|
+
prior.nightPresent = prior.nightPresent || o.nightPresent;
|
|
57
|
+
prior.samples += o.samples;
|
|
58
|
+
if (o.integrity) prior.integrity = { ...prior.integrity, ...o.integrity };
|
|
59
|
+
}
|
|
60
|
+
return [...byDay.values()];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Apply a batch of fixes (any order; sorted here) to the open-stay state.
|
|
65
|
+
* `offsetMinutes` is the device's UTC offset for the fold; it defaults to the
|
|
66
|
+
* device's own, and is a parameter only so the maths is testable in any TZ.
|
|
67
|
+
*/
|
|
68
|
+
export function applyLocationSamples(
|
|
69
|
+
pin: { lat: number; lng: number },
|
|
70
|
+
samples: ReadonlyArray<SampleFix>,
|
|
71
|
+
enterAt: number | null,
|
|
72
|
+
offsetMinutes?: number,
|
|
73
|
+
): SamplerResult {
|
|
74
|
+
let open = enterAt;
|
|
75
|
+
let observations: WireObservation[] = [];
|
|
76
|
+
const ordered = [...samples]
|
|
77
|
+
.filter((s) => Number.isFinite(s.timestamp))
|
|
78
|
+
.sort((a, b) => a.timestamp - b.timestamp);
|
|
79
|
+
|
|
80
|
+
for (const fix of ordered) {
|
|
81
|
+
if (fix.mocked === true) {
|
|
82
|
+
const { day, nightPresent } = localDayAndNight(new Date(fix.timestamp));
|
|
83
|
+
observations = mergeObservations(observations, [
|
|
84
|
+
{
|
|
85
|
+
day,
|
|
86
|
+
source: 'geofence',
|
|
87
|
+
dwellMinutes: 0,
|
|
88
|
+
nightPresent,
|
|
89
|
+
samples: 1,
|
|
90
|
+
integrity: { mockLocation: true },
|
|
91
|
+
},
|
|
92
|
+
]);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const inside = insideFence(pin, fix);
|
|
96
|
+
if (inside) {
|
|
97
|
+
if (open == null) open = fix.timestamp;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (open == null) continue;
|
|
101
|
+
const days = foldSpanIntoDays(open, fix.timestamp, offsetMinutes);
|
|
102
|
+
open = null;
|
|
103
|
+
if (days.length === 0) continue;
|
|
104
|
+
observations = mergeObservations(
|
|
105
|
+
observations,
|
|
106
|
+
days.map((d) => ({ ...d, source: 'geofence' as const, samples: 1 })),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
return { enterAt: open, observations };
|
|
110
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Tier visibility + recovery. A revoked permission silently downgrades the
|
|
3
|
+
// presence tier, and "silently" is the defect: the host app cannot ask the
|
|
4
|
+
// person to restore what it does not know is gone. presenceStatus() answers
|
|
5
|
+
// "which tier is actually running for this user?", and openLocationSettings()
|
|
6
|
+
// is the only honest recovery path — neither OS allows re-prompting in-app
|
|
7
|
+
// after a denial, so the road back runs through Settings.
|
|
8
|
+
//
|
|
9
|
+
// The phone's LOCATION SERVICES toggle is checked as well as the permissions.
|
|
10
|
+
// Permission granted with the toggle off produced `no_fix` on every report
|
|
11
|
+
// and nothing said why; OkHi's integration guidance calls this out for the
|
|
12
|
+
// same reason. It is a first-class input to the tier (tier.ts).
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
import { Linking, Platform } from 'react-native';
|
|
16
|
+
import * as Location from 'expo-location';
|
|
17
|
+
import { foregroundServiceRunning } from './foreground-service';
|
|
18
|
+
import { geofenceArmed } from './geofence';
|
|
19
|
+
import { loadPresencePin } from './store';
|
|
20
|
+
import { resolvePresenceTier, type PermissionState, type PresenceTier } from './tier';
|
|
21
|
+
|
|
22
|
+
export type { PresenceTier } from './tier';
|
|
23
|
+
|
|
24
|
+
export interface PresenceStatus {
|
|
25
|
+
/** The tier that is ACTUALLY running, not the one that was asked for. */
|
|
26
|
+
tier: PresenceTier;
|
|
27
|
+
/** Whether a pin is stored for this user (without one, no tier can run). */
|
|
28
|
+
pinStored: boolean;
|
|
29
|
+
/** Whether the stored pin is the always-on arrangement. */
|
|
30
|
+
alwaysOn: boolean;
|
|
31
|
+
/** The phone's location services switch. Off, nothing can run whatever
|
|
32
|
+
* the permissions say; `openLocationSettings('services')` is the road back. */
|
|
33
|
+
locationServicesEnabled: boolean;
|
|
34
|
+
foregroundPermission: PermissionState;
|
|
35
|
+
backgroundPermission: PermissionState;
|
|
36
|
+
/** The geofence is registered with the OS right now. */
|
|
37
|
+
geofenceArmed: boolean;
|
|
38
|
+
/** The Android foreground service is running right now (always false on iOS). */
|
|
39
|
+
foregroundServiceRunning: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function normalise(status: string | undefined): PermissionState {
|
|
43
|
+
if (status === 'granted') return 'granted';
|
|
44
|
+
if (status === 'denied') return 'denied';
|
|
45
|
+
return 'undetermined';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function presenceStatus(externalUserId: string): Promise<PresenceStatus> {
|
|
49
|
+
const pin = loadPresencePin(externalUserId);
|
|
50
|
+
const [fg, bg, armed, services, service] = await Promise.all([
|
|
51
|
+
Location.getForegroundPermissionsAsync().catch(() => null),
|
|
52
|
+
Location.getBackgroundPermissionsAsync().catch(() => null),
|
|
53
|
+
geofenceArmed(),
|
|
54
|
+
// An unanswerable check reads as ON: the permissions below still gate,
|
|
55
|
+
// and a false "off" would send people to Settings for nothing.
|
|
56
|
+
Location.hasServicesEnabledAsync().catch(() => true),
|
|
57
|
+
foregroundServiceRunning(),
|
|
58
|
+
]);
|
|
59
|
+
const inputs = {
|
|
60
|
+
pinStored: pin != null,
|
|
61
|
+
locationServicesEnabled: services,
|
|
62
|
+
foregroundPermission: normalise(fg?.status),
|
|
63
|
+
backgroundPermission: normalise(bg?.status),
|
|
64
|
+
geofenceArmed: armed,
|
|
65
|
+
foregroundServiceRunning: service,
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
tier: resolvePresenceTier(inputs),
|
|
69
|
+
alwaysOn: pin?.alwaysOn === true,
|
|
70
|
+
...inputs,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Deep-link to Settings — the only road back after a denial or a switched-off
|
|
76
|
+
* toggle. `'app'` (default) opens the app's own settings page (permissions);
|
|
77
|
+
* `'services'` opens the phone's location-services screen on Android, which
|
|
78
|
+
* is where the toggle lives (iOS has no such deep link, so it falls back to
|
|
79
|
+
* the app page). Never throws; a host may call it straight from a button.
|
|
80
|
+
*/
|
|
81
|
+
export async function openLocationSettings(target: 'app' | 'services' = 'app'): Promise<void> {
|
|
82
|
+
try {
|
|
83
|
+
if (target === 'services' && Platform.OS === 'android') {
|
|
84
|
+
await Linking.sendIntent('android.settings.LOCATION_SOURCE_SETTINGS');
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
await Linking.openSettings();
|
|
88
|
+
} catch {
|
|
89
|
+
// Nothing to do: the OS refused, and the host's copy already told the
|
|
90
|
+
// person where to go by hand.
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// The on-device pin store. When a workflow enables presence verification, the
|
|
3
|
+
// captured pin is saved LOCALLY (keyed by the org's user reference) so later
|
|
4
|
+
// app opens can evaluate "am I at the address?" without the coordinates ever
|
|
5
|
+
// leaving the phone. A small JSON file in the app's document directory via
|
|
6
|
+
// expo-file-system — loaded through the SDK's optional-module pattern, so a
|
|
7
|
+
// host app without the module simply has no presence tier (never a crash).
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
import { readJsonFile, writeJsonFile } from './fs';
|
|
11
|
+
|
|
12
|
+
export interface StoredPin {
|
|
13
|
+
lat: number;
|
|
14
|
+
lng: number;
|
|
15
|
+
savedAt: string;
|
|
16
|
+
/** Always-on monitoring: the pin lives as long as the arrangement does
|
|
17
|
+
* (cleared only by clearPresencePin — revoke or sign-out), because the
|
|
18
|
+
* server keeps renewing the watch and reports must keep flowing. */
|
|
19
|
+
alwaysOn?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const FILE_NAME = 'myaza-kyc-presence.json';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* How long a stored pin may drive reports. The watch it feeds resolves within
|
|
26
|
+
* its policy window (30 days at the longest) and the server discards reports
|
|
27
|
+
* for a resolved watch anyway — but the DEVICE kept sampling location on
|
|
28
|
+
* every app open forever, for a check that had finished. The server cannot
|
|
29
|
+
* say "stop" without breaking the ingest endpoint's enumeration safety (an
|
|
30
|
+
* unknown user and a resolved watch must answer identically), so the bound
|
|
31
|
+
* lives here: longest window plus resolution slack, then the pin self-expires
|
|
32
|
+
* and the reporter goes quiet before ever touching the GPS.
|
|
33
|
+
*/
|
|
34
|
+
export const PIN_TTL_DAYS = 45;
|
|
35
|
+
|
|
36
|
+
export function pinExpired(pin: StoredPin, nowMs: number = Date.now()): boolean {
|
|
37
|
+
// An always-on pin has no end date by design (the OkHi model): monitoring
|
|
38
|
+
// continues until revoked, and the TTL below exists only for BOUNDED
|
|
39
|
+
// watches whose purpose has a deadline.
|
|
40
|
+
if (pin.alwaysOn === true) return false;
|
|
41
|
+
const saved = Date.parse(pin.savedAt);
|
|
42
|
+
// An unparseable stamp is treated as expired: both platforms have always
|
|
43
|
+
// written savedAt, so a missing one is corruption, and corrupt entries must
|
|
44
|
+
// age out rather than report forever.
|
|
45
|
+
if (Number.isNaN(saved)) return true;
|
|
46
|
+
// Strictly past the TTL — the Flutter mirror compares the same way.
|
|
47
|
+
return nowMs - saved > PIN_TTL_DAYS * 24 * 60 * 60 * 1000;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readAll(): Record<string, StoredPin> {
|
|
51
|
+
return readJsonFile(FILE_NAME) as Record<string, StoredPin>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function writeAll(pins: Record<string, StoredPin>): void {
|
|
55
|
+
writeJsonFile(FILE_NAME, pins as Record<string, unknown>);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Saves the captured pin for later foreground reports. Never throws. */
|
|
59
|
+
export function savePresencePin(
|
|
60
|
+
externalUserId: string,
|
|
61
|
+
pin: { lat: number; lng: number },
|
|
62
|
+
opts?: { alwaysOn?: boolean },
|
|
63
|
+
): void {
|
|
64
|
+
if (!externalUserId) return;
|
|
65
|
+
const pins = readAll();
|
|
66
|
+
pins[externalUserId] = {
|
|
67
|
+
lat: pin.lat,
|
|
68
|
+
lng: pin.lng,
|
|
69
|
+
savedAt: new Date().toISOString(),
|
|
70
|
+
...(opts?.alwaysOn === true ? { alwaysOn: true } : {}),
|
|
71
|
+
};
|
|
72
|
+
writeAll(pins);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The stored pin for a user, or null (no store / never captured here /
|
|
76
|
+
* aged out — an expired pin is cleared on the way through). */
|
|
77
|
+
export function loadPresencePin(externalUserId: string): StoredPin | null {
|
|
78
|
+
const pin = readAll()[externalUserId];
|
|
79
|
+
if (!pin || typeof pin.lat !== 'number' || typeof pin.lng !== 'number') return null;
|
|
80
|
+
if (pinExpired(pin)) {
|
|
81
|
+
clearPresencePin(externalUserId);
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
return pin;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Drops a stored pin (e.g. after a watch resolves or the user signs out). */
|
|
88
|
+
export function clearPresencePin(externalUserId: string): void {
|
|
89
|
+
const pins = readAll();
|
|
90
|
+
if (externalUserId in pins) {
|
|
91
|
+
delete pins[externalUserId];
|
|
92
|
+
writeAll(pins);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Which presence tier is ACTUALLY running, as one pure decision — so the
|
|
3
|
+
// answer presenceStatus() gives can be pinned without a device, and so the
|
|
4
|
+
// Flutter mirror (presence_tier.dart) can be held to the same table.
|
|
5
|
+
//
|
|
6
|
+
// The location-services toggle sits ABOVE every permission: with services
|
|
7
|
+
// off, a granted permission and an armed fence produce no fix and no
|
|
8
|
+
// transition, so nothing is running whatever the grants say. That was the
|
|
9
|
+
// silent case — permission granted, toggle off, `no_fix` forever — and it is
|
|
10
|
+
// why the switch is a first-class input here rather than folded into one of
|
|
11
|
+
// the permission states.
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
export type PresenceTier = 'background' | 'foreground' | 'none';
|
|
15
|
+
|
|
16
|
+
export type PermissionState = 'granted' | 'denied' | 'undetermined';
|
|
17
|
+
|
|
18
|
+
export interface TierInputs {
|
|
19
|
+
pinStored: boolean;
|
|
20
|
+
locationServicesEnabled: boolean;
|
|
21
|
+
foregroundPermission: PermissionState;
|
|
22
|
+
backgroundPermission: PermissionState;
|
|
23
|
+
/** The OS geofence is registered right now. */
|
|
24
|
+
geofenceArmed: boolean;
|
|
25
|
+
/** The Android foreground service is running right now. */
|
|
26
|
+
foregroundServiceRunning: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function resolvePresenceTier(i: TierInputs): PresenceTier {
|
|
30
|
+
if (!i.pinStored || !i.locationServicesEnabled) return 'none';
|
|
31
|
+
if (i.backgroundPermission === 'granted' && (i.geofenceArmed || i.foregroundServiceRunning)) {
|
|
32
|
+
return 'background';
|
|
33
|
+
}
|
|
34
|
+
if (i.foregroundPermission === 'granted') return 'foreground';
|
|
35
|
+
return 'none';
|
|
36
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Waiting for the watch. The server mints an AddressWatch in the post-terminal
|
|
3
|
+
// hook chain, seconds AFTER a submission is accepted, and its ingest is
|
|
4
|
+
// enumeration-safe by contract: an observation for a user with no live watch
|
|
5
|
+
// answers `accepted: 0` exactly as an unknown user would, and is dropped. So
|
|
6
|
+
// a report made the moment onSubmit fires — the natural place for a host to
|
|
7
|
+
// make one — landed on nothing (iPhone + S24, 2026-09-07: three watches in a
|
|
8
|
+
// row lapsed INCONCLUSIVE with zero observations while both phones sat at the
|
|
9
|
+
// pin). The reporter therefore asks where the watch stands before it posts,
|
|
10
|
+
// and when the pin was captured minutes ago it WAITS for the watch to appear,
|
|
11
|
+
// bounded, rather than posting into the gap. Mirrors the Flutter SDK's
|
|
12
|
+
// presence_watch_wait.dart — keep the two in lockstep.
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
import { resolveBaseUrl } from '../services/resolveUrl';
|
|
16
|
+
|
|
17
|
+
/** A pin saved this recently was captured by a flow whose watch may still be minting. */
|
|
18
|
+
export const FRESH_PIN_MS = 15 * 60 * 1000;
|
|
19
|
+
/** How long a submit-time report waits for its watch before giving up. */
|
|
20
|
+
export const WATCH_WAIT_MS = 90 * 1000;
|
|
21
|
+
export const WATCH_POLL_MS = 3 * 1000;
|
|
22
|
+
|
|
23
|
+
export type WatchPresence = 'live' | 'absent' | 'unknown';
|
|
24
|
+
|
|
25
|
+
export function pinIsFresh(pin: { savedAt: string }, nowMs: number = Date.now()): boolean {
|
|
26
|
+
const saved = Date.parse(pin.savedAt);
|
|
27
|
+
return !Number.isNaN(saved) && nowMs - saved <= FRESH_PIN_MS;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The server's public status for the user's watch, or null when it could not be read. */
|
|
31
|
+
export async function fetchWatchStatus(
|
|
32
|
+
apiKey: string,
|
|
33
|
+
devUrl: string | undefined,
|
|
34
|
+
externalUserId: string,
|
|
35
|
+
): Promise<string | null> {
|
|
36
|
+
try {
|
|
37
|
+
const base = resolveBaseUrl(apiKey, devUrl);
|
|
38
|
+
const response = await fetch(
|
|
39
|
+
`${base}/api/kyc/address/presence/${encodeURIComponent(externalUserId)}`,
|
|
40
|
+
{ headers: { Authorization: `Bearer ${apiKey}` } },
|
|
41
|
+
);
|
|
42
|
+
if (!response.ok) return null;
|
|
43
|
+
const body = (await response.json()) as { status?: unknown };
|
|
44
|
+
return typeof body.status === 'string' ? body.status : null;
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface AwaitWatchDeps {
|
|
51
|
+
fetchStatus: () => Promise<string | null>;
|
|
52
|
+
sleep?: (ms: number) => Promise<void>;
|
|
53
|
+
now?: () => number;
|
|
54
|
+
waitMs?: number;
|
|
55
|
+
pollMs?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const realSleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Whether a live watch exists to receive a report. Only `in_progress` is
|
|
62
|
+
* live: a resolved cycle, or none at all, drops what is posted. An
|
|
63
|
+
* unreadable status is `unknown`, and the post goes ahead — a network doubt
|
|
64
|
+
* must not silence a report the server might accept. A FRESH pin changes the
|
|
65
|
+
* rule: its watch is probably still being minted (or the previous cycle has
|
|
66
|
+
* just lapsed and the new one is about to replace it), so the status is
|
|
67
|
+
* re-read until it turns live or the wait runs out.
|
|
68
|
+
*/
|
|
69
|
+
export async function awaitWatch(fresh: boolean, deps: AwaitWatchDeps): Promise<WatchPresence> {
|
|
70
|
+
const sleep = deps.sleep ?? realSleep;
|
|
71
|
+
const now = deps.now ?? Date.now;
|
|
72
|
+
const deadline = now() + (deps.waitMs ?? WATCH_WAIT_MS);
|
|
73
|
+
const pollMs = deps.pollMs ?? WATCH_POLL_MS;
|
|
74
|
+
for (;;) {
|
|
75
|
+
const status = await deps.fetchStatus();
|
|
76
|
+
if (status === 'in_progress') return 'live';
|
|
77
|
+
if (!fresh) return status === null ? 'unknown' : 'absent';
|
|
78
|
+
if (now() >= deadline) return status === null ? 'unknown' : 'absent';
|
|
79
|
+
await sleep(pollMs);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import React, { useMemo } from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { radius, spacing } from '../config/theme';
|
|
5
|
+
import { useKyc, useKycConfig, useKycStore, useTheme } from '../components/runtime';
|
|
6
|
+
import { MyazaText } from '../components/Typography';
|
|
7
|
+
import { CountryField } from '../components/CountryField';
|
|
8
|
+
import { CountryFlag } from '../components/CountryFlag';
|
|
9
|
+
import { ALL_REGION_CODES, regionCountryName } from '../config/regions';
|
|
10
|
+
import { configScope } from '../lib/scope';
|
|
11
|
+
import { inferredCountry } from '../lib/inferred-country';
|
|
12
|
+
import type { SupportedCountry } from '../types/config';
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// The declared-country control for ADDRESS-SCOPED flows — mounted on the
|
|
16
|
+
// Proof of Address step (the Didit PoA model: the applicant names their
|
|
17
|
+
// market, then uploads the document). Mirrors the web SDK's
|
|
18
|
+
// steps/address/AddressCountryControl: the pick drives the document kinds on
|
|
19
|
+
// offer, the search filter, the map's opening view, the PoA vendor market,
|
|
20
|
+
// and rides the submission as the verification's country. The pin stays the
|
|
21
|
+
// ground truth regardless.
|
|
22
|
+
//
|
|
23
|
+
// The picker is the house country sheet (CountryField) in its region-grouped
|
|
24
|
+
// mode, with the inferred country (server geo first, device locale second)
|
|
25
|
+
// pinned on top as "Your location" — the web's region menu, in the sheet the
|
|
26
|
+
// rest of the mobile flow uses (user decision 2026-09-06).
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
/** What the picker offers: the org's accepted list, else every country. */
|
|
30
|
+
export function poaOfferedCountries(configured: string[] | undefined): string[] {
|
|
31
|
+
const list = (configured ?? [])
|
|
32
|
+
.map((c) => c.toUpperCase())
|
|
33
|
+
.filter((c) => ALL_REGION_CODES.includes(c));
|
|
34
|
+
return list.length > 0 ? list : ALL_REGION_CODES;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function AddressCountryControl(): React.ReactElement | null {
|
|
38
|
+
const config = useKycConfig();
|
|
39
|
+
const store = useKycStore();
|
|
40
|
+
const { colors } = useTheme();
|
|
41
|
+
const selected = useKyc((s) => s.selectedCountry);
|
|
42
|
+
const geo = useKyc((s) => s.serverConfig.geoCountry);
|
|
43
|
+
|
|
44
|
+
const scoped = configScope(config) === 'address';
|
|
45
|
+
const offered = useMemo(
|
|
46
|
+
() => poaOfferedCountries(config.proofOfAddress?.countries),
|
|
47
|
+
[config.proofOfAddress?.countries],
|
|
48
|
+
);
|
|
49
|
+
const options = useMemo(
|
|
50
|
+
() => offered.map((code) => ({ code, name: regionCountryName(code) })),
|
|
51
|
+
[offered],
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
if (!scoped) return null;
|
|
55
|
+
|
|
56
|
+
// Only a country the applicant PICKED shows as selected: the address scope
|
|
57
|
+
// has no seeded country to show (web's AddressCountryControl, same rule), and
|
|
58
|
+
// showing config.country here while the attachment area's flag read
|
|
59
|
+
// `selectedCountry` had the control and the drop zone disagreeing.
|
|
60
|
+
const value = selected ?? null;
|
|
61
|
+
const pick = (code: string) => store.getState().setCountry(code as SupportedCountry);
|
|
62
|
+
|
|
63
|
+
// One accepted country = nothing to pick. Show it as a settled fact rather
|
|
64
|
+
// than a sheet that could only ever re-answer itself.
|
|
65
|
+
if (offered.length === 1) {
|
|
66
|
+
const only = offered[0]!;
|
|
67
|
+
// The label sits ABOVE the field, as it does on every other input (user
|
|
68
|
+
// decision 2026-09-05) — the read-only box is CountryField's trigger
|
|
69
|
+
// without the chevron, so the settled and pickable states line up.
|
|
70
|
+
return (
|
|
71
|
+
<View style={{ marginBottom: spacing.lg }}>
|
|
72
|
+
<MyazaText variant="bodySmall" style={{ fontWeight: '600', marginBottom: spacing.xs }}>
|
|
73
|
+
Country
|
|
74
|
+
</MyazaText>
|
|
75
|
+
<View
|
|
76
|
+
style={{
|
|
77
|
+
flexDirection: 'row',
|
|
78
|
+
alignItems: 'center',
|
|
79
|
+
paddingHorizontal: spacing.md,
|
|
80
|
+
paddingVertical: spacing.md,
|
|
81
|
+
borderRadius: radius.sm,
|
|
82
|
+
borderWidth: 1,
|
|
83
|
+
borderColor: colors.border,
|
|
84
|
+
backgroundColor: colors.backgroundSecondary,
|
|
85
|
+
}}
|
|
86
|
+
>
|
|
87
|
+
<CountryFlag country={only} size={20} />
|
|
88
|
+
<View style={{ width: spacing.sm }} />
|
|
89
|
+
<MyazaText variant="body" style={{ flex: 1 }} numberOfLines={1}>
|
|
90
|
+
{regionCountryName(only)}
|
|
91
|
+
</MyazaText>
|
|
92
|
+
</View>
|
|
93
|
+
</View>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<View style={{ marginBottom: spacing.lg }}>
|
|
99
|
+
<MyazaText variant="bodySmall" style={{ fontWeight: '600', marginBottom: spacing.xs }}>
|
|
100
|
+
Country
|
|
101
|
+
</MyazaText>
|
|
102
|
+
<CountryField
|
|
103
|
+
value={value}
|
|
104
|
+
options={options}
|
|
105
|
+
onChange={pick}
|
|
106
|
+
// The inferred country pinned on top of the sheet; the sheet itself
|
|
107
|
+
// drops it when the org's list does not carry it.
|
|
108
|
+
geoCountry={inferredCountry(geo)}
|
|
109
|
+
grouped
|
|
110
|
+
placeholder="Select country"
|
|
111
|
+
searchPlaceholder="Search countries"
|
|
112
|
+
/>
|
|
113
|
+
</View>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -50,11 +50,13 @@ export function ApplicantRoleStep(): React.ReactElement {
|
|
|
50
50
|
const { colors } = useTheme();
|
|
51
51
|
const stored = useKyc((s) => s.businessApplication);
|
|
52
52
|
|
|
53
|
-
// The valid entered
|
|
54
|
-
// is index-based, so the list and the submission can never disagree.
|
|
53
|
+
// The valid entered PEOPLE, keeping their ORIGINAL index — the payload flag
|
|
54
|
+
// is index-based, so the list and the submission can never disagree. A
|
|
55
|
+
// corporate shareholder is excluded: "which of these is you?" is a question
|
|
56
|
+
// about humans, and a company can never be the person filling in the form.
|
|
55
57
|
const people = stored.keyPeople
|
|
56
58
|
.map((row, index) => ({ row, index }))
|
|
57
|
-
.filter(({ row }) => isKeyPersonRowValid(row));
|
|
59
|
+
.filter(({ row }) => !row.isCorporate && isKeyPersonRowValid(row));
|
|
58
60
|
const hasPeople = people.length > 0;
|
|
59
61
|
|
|
60
62
|
const propName = [config.userData?.firstName, config.userData?.lastName]
|
|
@@ -63,13 +65,15 @@ export function ApplicantRoleStep(): React.ReactElement {
|
|
|
63
65
|
|
|
64
66
|
// Tri-state: a person's index, 'other', or nothing chosen yet. First arrival
|
|
65
67
|
// pre-selects the applicant's own entry when the userData name matches
|
|
66
|
-
// (they still confirm) —
|
|
68
|
+
// (they still confirm) — and when the name matches NOBODY listed, "I'm not
|
|
69
|
+
// one of these people" is what that fact means, so it is pre-selected
|
|
70
|
+
// instead (mirrors the web SDK). A stored choice always wins.
|
|
67
71
|
const [selection, setSelection] = useState<number | 'other' | null>(() => {
|
|
68
72
|
if (stored.applicantKeyPersonIndex !== null) return stored.applicantKeyPersonIndex;
|
|
69
73
|
if (stored.applicantRole) return 'other';
|
|
70
74
|
if (propName) {
|
|
71
75
|
const match = people.find(({ row }) => namesLooselyMatch(propName, row.name));
|
|
72
|
-
|
|
76
|
+
return match ? match.index : people.length > 0 ? 'other' : null;
|
|
73
77
|
}
|
|
74
78
|
return null;
|
|
75
79
|
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { radius, spacing } from '../config/theme';
|
|
5
|
+
import { useTheme } from '../components/runtime';
|
|
6
|
+
import { MyazaText } from '../components/Typography';
|
|
7
|
+
import { MyazaAlert } from '../components/MyazaAlert';
|
|
8
|
+
import type { BusinessCheckState } from '../store/state';
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// What the register said, when it was NOT a plain yes.
|
|
12
|
+
//
|
|
13
|
+
// The check is a paid, deliberate step, so it is shown rather than run
|
|
14
|
+
// invisibly: the applicant sees a company that is not on the register caught
|
|
15
|
+
// HERE instead of after documents and a selfie. Nothing is shown for a company
|
|
16
|
+
// that WAS found — the register's answer is already in the form the applicant
|
|
17
|
+
// is looking at, editable, and its officers are the next step. Mirrors the web
|
|
18
|
+
// SDK's BusinessCheckPanel.
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
export function BusinessCheckPanel({
|
|
22
|
+
check,
|
|
23
|
+
}: {
|
|
24
|
+
check: BusinessCheckState;
|
|
25
|
+
}): React.ReactElement | null {
|
|
26
|
+
// `skipped` shows nothing on purpose. The organisation could not be charged,
|
|
27
|
+
// which is not the applicant's problem and not something they can act on;
|
|
28
|
+
// the check simply happens at submission instead. `checking` shows nothing
|
|
29
|
+
// either — the loader lives inside the Continue button they just pressed.
|
|
30
|
+
if (
|
|
31
|
+
check.status === 'idle' ||
|
|
32
|
+
check.status === 'skipped' ||
|
|
33
|
+
check.status === 'found' ||
|
|
34
|
+
check.status === 'checking'
|
|
35
|
+
) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (check.status === 'not_found') {
|
|
40
|
+
return (
|
|
41
|
+
<MyazaAlert
|
|
42
|
+
variant="warning"
|
|
43
|
+
title="We could not find this business on the register"
|
|
44
|
+
message="Check the registration number and try again."
|
|
45
|
+
/>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (check.status === 'limit_reached') {
|
|
50
|
+
// Not a failure: the submission still runs its own check. What it says is
|
|
51
|
+
// "stop re-picking and look at the number", which is the only thing left
|
|
52
|
+
// that helps.
|
|
53
|
+
return (
|
|
54
|
+
<NeutralPanel
|
|
55
|
+
title="We have stopped looking this up for now"
|
|
56
|
+
body="This application has searched the register several times. Check the registration number is right; your details will still be verified when you submit."
|
|
57
|
+
/>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// unavailable — deliberately not "we could not find it": an outage is not
|
|
62
|
+
// evidence that a business is unregistered.
|
|
63
|
+
return (
|
|
64
|
+
<NeutralPanel
|
|
65
|
+
title="The register is temporarily unavailable"
|
|
66
|
+
body="You can continue, and we will check it shortly."
|
|
67
|
+
/>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function NeutralPanel({ title, body }: { title: string; body: string }): React.ReactElement {
|
|
72
|
+
const { colors } = useTheme();
|
|
73
|
+
return (
|
|
74
|
+
<View
|
|
75
|
+
style={{
|
|
76
|
+
borderWidth: 1,
|
|
77
|
+
borderColor: colors.border,
|
|
78
|
+
borderRadius: radius.sm,
|
|
79
|
+
backgroundColor: colors.backgroundSecondary,
|
|
80
|
+
padding: spacing.md,
|
|
81
|
+
}}
|
|
82
|
+
>
|
|
83
|
+
<MyazaText variant="label">{title}</MyazaText>
|
|
84
|
+
<MyazaText variant="bodySmall" color={colors.textSecondary} style={{ marginTop: 2 }}>
|
|
85
|
+
{body}
|
|
86
|
+
</MyazaText>
|
|
87
|
+
</View>
|
|
88
|
+
);
|
|
89
|
+
}
|