@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
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import { useKyc, useKycConfig, useKycStore } from '../../components/runtime';
|
|
4
|
+
import { addressFlowFor } from '../../config/addressCollection';
|
|
5
|
+
import { webViewAvailable } from '../../lib/webview-available';
|
|
6
|
+
import { isBusinessFlow } from '../../config/business';
|
|
7
|
+
import { addressFlowSteps, addressVendorsStubbed, isAddressStep } from '../../lib/address-flow';
|
|
8
|
+
import { addressExitStep, recoverAddressStep } from '../../lib/address-step-recovery';
|
|
9
|
+
import { defaultMapView } from '../../lib/map-tiles';
|
|
10
|
+
import { buildStepOrder, nextStepInOrder } from '../../config/stepOrder';
|
|
11
|
+
import { geoDefaultCountry } from '../../lib/country-adoption';
|
|
12
|
+
import { configScope } from '../../lib/scope';
|
|
13
|
+
import { stepOrderOptions } from '../../store/kycStore';
|
|
14
|
+
import { deviceFixFields } from '../../services/location';
|
|
15
|
+
import { savePresencePin } from '../../presence/store';
|
|
16
|
+
import { usePinActions } from './use-pin-actions';
|
|
17
|
+
import type { KYCStep } from '../../types/config';
|
|
18
|
+
import type { AddressState } from '../../store/state';
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// The address flow's shared brain: every address step mounts this hook and gets
|
|
22
|
+
// the same derived flags, the same step list, and the same actions — which is
|
|
23
|
+
// what keeps four separate screens telling one story.
|
|
24
|
+
//
|
|
25
|
+
// Navigation rides the SDK's ONE ordered step list rather than a second copy:
|
|
26
|
+
// the flow's steps are already in `buildStepOrder`, so stepping forward, going
|
|
27
|
+
// back, and backing out of the flow entirely all fall out of the same list the
|
|
28
|
+
// progress bar reads. That is why nothing here reimplements goBack — the
|
|
29
|
+
// sheet's own back button already walks it — and why goNext is the store's
|
|
30
|
+
// nextStep. `nextAddressStep`/`prevAddressStep` stay in lib/address-flow as
|
|
31
|
+
// part of the pure mirror the three SDKs share.
|
|
32
|
+
//
|
|
33
|
+
// A MIRROR of the web SDK's steps/address/use-address-flow.ts.
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
export function useAddressFlow() {
|
|
37
|
+
const config = useKycConfig();
|
|
38
|
+
const store = useKycStore();
|
|
39
|
+
const address = useKyc((s) => s.address);
|
|
40
|
+
const serverSearch = useKyc((s) => s.serverConfig.addressSearch === true);
|
|
41
|
+
const selectedCountry = useKyc((s) => s.selectedCountry);
|
|
42
|
+
const businessCountry = useKyc((s) => s.business.country);
|
|
43
|
+
const [uploading, setUploading] = useState(false);
|
|
44
|
+
const [confirming, setConfirming] = useState(false);
|
|
45
|
+
const [error, setError] = useState<string | null>(null);
|
|
46
|
+
|
|
47
|
+
const cfg = config.addressCollection;
|
|
48
|
+
const isBusiness = isBusinessFlow(config);
|
|
49
|
+
const vendorEnvironment = useKyc((s) => s.serverConfig.environment ?? null);
|
|
50
|
+
const mapsFrameUrl = useKyc((s) => s.serverConfig.mapsFrameUrl ?? null);
|
|
51
|
+
const flow = addressFlowFor(
|
|
52
|
+
config,
|
|
53
|
+
serverSearch,
|
|
54
|
+
addressVendorsStubbed({ environment: vendorEnvironment }),
|
|
55
|
+
Boolean(mapsFrameUrl) && webViewAvailable(),
|
|
56
|
+
);
|
|
57
|
+
// KYB: the premises pin + directions ARE the capture, so the flow is that
|
|
58
|
+
// single step and its Continue commits.
|
|
59
|
+
//
|
|
60
|
+
// Memoised on the three flags it derives from, so `steps` — and the two
|
|
61
|
+
// navigation callbacks that close over it — keep a stable identity. A fresh
|
|
62
|
+
// array on every render would make `goNext` a new function each time, and
|
|
63
|
+
// the entrance step depends on that identity inside an effect.
|
|
64
|
+
const { searchAvailable, photoMode, streetViewOffered } = flow;
|
|
65
|
+
const steps: KYCStep[] = useMemo(
|
|
66
|
+
() =>
|
|
67
|
+
isBusiness
|
|
68
|
+
? ['address-collection']
|
|
69
|
+
: addressFlowSteps({ searchAvailable, photoMode, streetViewOffered }),
|
|
70
|
+
[isBusiness, searchAvailable, photoMode, streetViewOffered],
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// A resumed attempt restores the step it was saved on, and the flow may have
|
|
74
|
+
// changed shape since: `searchAvailable` is a SERVER flag that lands after the
|
|
75
|
+
// restore, and a republish can drop the entrance photo. Left alone the
|
|
76
|
+
// applicant sits on a screen the order does not contain, where Continue has
|
|
77
|
+
// nothing to advance to and Back has no predecessor. Gated on the config
|
|
78
|
+
// having settled: until then the order is not the real one.
|
|
79
|
+
const configSettled = useKyc((s) => s.serverConfig.status !== 'loading');
|
|
80
|
+
const currentStep = useKyc((s) => s.currentStep);
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (!configSettled || !isAddressStep(currentStep)) return;
|
|
83
|
+
const options = stepOrderOptions(store.getState());
|
|
84
|
+
const inFlow = buildStepOrder(options).filter(isAddressStep);
|
|
85
|
+
const exit = addressExitStep(buildStepOrder({ ...options, hasAddressCollection: true }));
|
|
86
|
+
const recovered = recoverAddressStep(currentStep, inFlow, exit);
|
|
87
|
+
if (recovered) store.getState().goToStep(recovered);
|
|
88
|
+
}, [configSettled, currentStep, store]);
|
|
89
|
+
|
|
90
|
+
// The address scope declares the visitor's IP country by default, ONCE,
|
|
91
|
+
// while nothing is declared; a geocode or a picked address then corrects
|
|
92
|
+
// the guess (lib/country-adoption.ts). A full flow never defaults from it.
|
|
93
|
+
const geoCountry = useKyc((s) => s.serverConfig.geoCountry ?? null);
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
const s = store.getState();
|
|
96
|
+
const country = geoDefaultCountry({
|
|
97
|
+
geoCountry,
|
|
98
|
+
selectedCountry: s.selectedCountry,
|
|
99
|
+
scope: configScope(s.config),
|
|
100
|
+
accepted: s.config.proofOfAddress?.countries,
|
|
101
|
+
});
|
|
102
|
+
if (country) s.setCountryAuto(country);
|
|
103
|
+
}, [geoCountry, store]);
|
|
104
|
+
|
|
105
|
+
const pin = address ? { lat: address.lat, lng: address.lng } : null;
|
|
106
|
+
const country = isBusiness ? (businessCountry ?? undefined) : (selectedCountry ?? config.country);
|
|
107
|
+
const view = defaultMapView(country);
|
|
108
|
+
|
|
109
|
+
const pinActions = usePinActions(store, setError);
|
|
110
|
+
|
|
111
|
+
/** Leave the address flow forwards: after the review, a KYB commit, a skip. */
|
|
112
|
+
const exitForward = useCallback(() => {
|
|
113
|
+
const state = store.getState();
|
|
114
|
+
const last = steps[steps.length - 1]!;
|
|
115
|
+
store.getState().goToStep(nextStepInOrder(last, stepOrderOptions(state)));
|
|
116
|
+
}, [steps, store]);
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The step after the current one within the flow, else the flow's exit:
|
|
120
|
+
* exactly what the store's own `nextStep` computes, since the flow's steps
|
|
121
|
+
* are contiguous in the ordered list. Going through the store also keeps
|
|
122
|
+
* the navigation direction and the camera flag handled in one place.
|
|
123
|
+
*/
|
|
124
|
+
const goNext = useCallback(() => store.getState().nextStep(), [store]);
|
|
125
|
+
|
|
126
|
+
const pickPhoto = useCallback(
|
|
127
|
+
async (upload: () => Promise<string>, previewUri: string) => {
|
|
128
|
+
setError(null);
|
|
129
|
+
setUploading(true);
|
|
130
|
+
try {
|
|
131
|
+
const mediaId = await upload();
|
|
132
|
+
store.getState().setAddressPhoto(mediaId);
|
|
133
|
+
store.getState().setAddressPhotoPreview(previewUri);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
setError(
|
|
136
|
+
err instanceof Error && err.message
|
|
137
|
+
? err.message
|
|
138
|
+
: 'Upload failed. Please check your connection and try again.',
|
|
139
|
+
);
|
|
140
|
+
} finally {
|
|
141
|
+
setUploading(false);
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
[store],
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const removePhoto = useCallback(() => {
|
|
148
|
+
store.getState().setAddressPhoto(null);
|
|
149
|
+
store.getState().setAddressPhotoPreview(null);
|
|
150
|
+
}, [store]);
|
|
151
|
+
|
|
152
|
+
/** Commit: the one-shot attest fix (best-effort), then leave the flow. */
|
|
153
|
+
const confirm = useCallback(async () => {
|
|
154
|
+
const current = store.getState().address;
|
|
155
|
+
if (!current) return;
|
|
156
|
+
setConfirming(true);
|
|
157
|
+
let committed: AddressState = current;
|
|
158
|
+
if (cfg?.attestPresence) {
|
|
159
|
+
// Best-effort by contract: a denied prompt or a slow read costs the
|
|
160
|
+
// `attested` tier, never the flow.
|
|
161
|
+
committed = { ...current, ...(await deviceFixFields()) };
|
|
162
|
+
store.getState().setAddress(committed);
|
|
163
|
+
}
|
|
164
|
+
// Presence verification keeps the confirmed pin ON-DEVICE so later
|
|
165
|
+
// foreground reports evaluate the fence locally. Keyed by the org's user
|
|
166
|
+
// reference: without one there is nothing to report against.
|
|
167
|
+
if (cfg?.presence?.enabled === true && config.userId) {
|
|
168
|
+
savePresencePin(
|
|
169
|
+
config.userId,
|
|
170
|
+
{ lat: committed.lat, lng: committed.lng },
|
|
171
|
+
{ alwaysOn: cfg.presence.alwaysOn === true },
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
setConfirming(false);
|
|
175
|
+
exitForward();
|
|
176
|
+
}, [cfg?.attestPresence, cfg?.presence?.enabled, config.userId, exitForward, store]);
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
cfg,
|
|
180
|
+
address,
|
|
181
|
+
isBusiness,
|
|
182
|
+
steps,
|
|
183
|
+
pin,
|
|
184
|
+
view,
|
|
185
|
+
country,
|
|
186
|
+
photoMode: flow.photoMode,
|
|
187
|
+
streetViewOffered: flow.streetViewOffered,
|
|
188
|
+
searchAvailable: flow.searchAvailable,
|
|
189
|
+
uploading,
|
|
190
|
+
confirming,
|
|
191
|
+
error,
|
|
192
|
+
setError,
|
|
193
|
+
pickPhoto,
|
|
194
|
+
removePhoto,
|
|
195
|
+
goNext,
|
|
196
|
+
exitForward,
|
|
197
|
+
confirm,
|
|
198
|
+
...pinActions,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import { REVERSE_DEBOUNCE_MS, addressVendorsStubbed, SAMPLE_ADDRESS_LINE } from '../../lib/address-flow';
|
|
4
|
+
import type { KycStore } from '../../store/state';
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Reverse geocoding: the pin's human-readable line. Split from
|
|
8
|
+
// use-pin-actions.ts (200-line rule).
|
|
9
|
+
//
|
|
10
|
+
// Debounced, because a drag settles several times, and DROPPED when the pin
|
|
11
|
+
// moved again meanwhile: the comparison is exact coordinate equality, so a
|
|
12
|
+
// late answer can never label somewhere the applicant has already left.
|
|
13
|
+
// Failures are swallowed; the line is a convenience. Display only, and the
|
|
14
|
+
// pin's own country is handed to `onGeocoded` so the declaration can follow
|
|
15
|
+
// the evidence (lib/country-adoption.ts). Mirrors Flutter's
|
|
16
|
+
// address_pin_label.dart.
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
export function useLabelPin(
|
|
20
|
+
store: KycStore,
|
|
21
|
+
onGeocoded: (country: string | null | undefined) => void,
|
|
22
|
+
): { labelPin: (lat: number, lng: number, delay?: number) => void; labelling: boolean } {
|
|
23
|
+
const reverseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
24
|
+
// The summary line shows nothing while a pin has no address, so it has to
|
|
25
|
+
// say WHY: an answer is coming, or none is.
|
|
26
|
+
const [labelling, setLabelling] = useState(false);
|
|
27
|
+
|
|
28
|
+
useEffect(
|
|
29
|
+
() => () => {
|
|
30
|
+
if (reverseTimer.current) clearTimeout(reverseTimer.current);
|
|
31
|
+
},
|
|
32
|
+
[],
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const labelPin = useCallback(
|
|
36
|
+
(lat: number, lng: number, delay = REVERSE_DEBOUNCE_MS) => {
|
|
37
|
+
if (reverseTimer.current) clearTimeout(reverseTimer.current);
|
|
38
|
+
setLabelling(true);
|
|
39
|
+
// SANDBOX never reverse-geocodes: the sample line demonstrates the
|
|
40
|
+
// summary card with no network call (the web SDK's preview/sandbox rule).
|
|
41
|
+
if (addressVendorsStubbed({ environment: store.getState().serverConfig.environment })) {
|
|
42
|
+
reverseTimer.current = setTimeout(() => {
|
|
43
|
+
const cur = store.getState().address;
|
|
44
|
+
if (!cur || cur.lat !== lat || cur.lng !== lng || cur.label) return;
|
|
45
|
+
store.getState().setAddress({ ...cur, label: SAMPLE_ADDRESS_LINE });
|
|
46
|
+
setLabelling(false);
|
|
47
|
+
}, 0);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
reverseTimer.current = setTimeout(() => {
|
|
51
|
+
void store
|
|
52
|
+
.getState()
|
|
53
|
+
.api.addressReverse(lat, lng)
|
|
54
|
+
.then((r) => {
|
|
55
|
+
const cur = store.getState().address;
|
|
56
|
+
// Exact coordinate equality: anything else means the pin moved
|
|
57
|
+
// while the request was out, and this answer describes a spot the
|
|
58
|
+
// applicant has already left.
|
|
59
|
+
if (!r.line || !cur || cur.lat !== lat || cur.lng !== lng) return;
|
|
60
|
+
store.getState().setAddress({
|
|
61
|
+
...cur,
|
|
62
|
+
label: r.line,
|
|
63
|
+
...(r.parts ? { parts: r.parts } : {}),
|
|
64
|
+
// A resolved street retires the typed one (its input hides) —
|
|
65
|
+
// back to UNDEFINED, never '': the details sheet reads '' as
|
|
66
|
+
// deliberately cleared, which suppressed the resolved-street
|
|
67
|
+
// prefill it should be showing.
|
|
68
|
+
...(r.parts?.street ? { street: undefined } : {}),
|
|
69
|
+
});
|
|
70
|
+
onGeocoded(r.parts?.country);
|
|
71
|
+
})
|
|
72
|
+
.catch(() => undefined)
|
|
73
|
+
.finally(() => setLabelling(false));
|
|
74
|
+
}, delay);
|
|
75
|
+
},
|
|
76
|
+
[onGeocoded, store],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
return { labelPin, labelling };
|
|
80
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import { fixApplied, pinMove, withoutPickedLabel } from '../../lib/address-pin-move';
|
|
4
|
+
import { isAddressStep } from '../../lib/address-flow';
|
|
5
|
+
import {
|
|
6
|
+
currentFix as cachedFix,
|
|
7
|
+
currentFixFailure,
|
|
8
|
+
locating as fixInFlight,
|
|
9
|
+
locationFailureMessage,
|
|
10
|
+
prefetchCurrentFix,
|
|
11
|
+
type CurrentFix,
|
|
12
|
+
} from '../../lib/address-current-location';
|
|
13
|
+
import { adoptionDecision } from '../../lib/country-adoption';
|
|
14
|
+
import { configScope } from '../../lib/scope';
|
|
15
|
+
import { useLabelPin } from './use-label-pin';
|
|
16
|
+
import { fixSourceFor } from './fix-source';
|
|
17
|
+
import type { KycStore } from '../../store/state';
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// The pin's mechanics — the shared current-location fix and every way the pin
|
|
21
|
+
// can move. Split from use-address-flow.ts (200-line rule); the reverse
|
|
22
|
+
// geocode that labels the pin lives in use-label-pin.ts, the fix source in
|
|
23
|
+
// fix-source.ts. A MIRROR of the web SDK's steps/address/use-pin-actions.ts.
|
|
24
|
+
//
|
|
25
|
+
// One rule runs through all of it: a HUMAN-CONFIRMED label is never silently
|
|
26
|
+
// discarded, and never silently kept against the applicant's wishes either.
|
|
27
|
+
// They decide, via the pin step's keep/update row.
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
export interface PinActions {
|
|
31
|
+
currentFix: CurrentFix | null;
|
|
32
|
+
locating: boolean;
|
|
33
|
+
startPrefetch: () => void;
|
|
34
|
+
applyCurrentFix: (onDone?: () => void, opts?: { silent?: boolean }) => Promise<void>;
|
|
35
|
+
setPin: (next: { lat: number; lng: number }, accuracy?: number | null) => void;
|
|
36
|
+
keepPickedLabel: () => void;
|
|
37
|
+
adoptPinAddress: () => void;
|
|
38
|
+
relabelPin: () => void;
|
|
39
|
+
locateToPin: () => Promise<void>;
|
|
40
|
+
/** The declared country follows the evidence: the rule, and why each of
|
|
41
|
+
* its guards exists, is lib/country-adoption.ts. */
|
|
42
|
+
adoptGeocodedCountry: (country: string | null | undefined, opts?: { explicit?: boolean }) => void;
|
|
43
|
+
/** A reverse geocode is out: the pin has no line YET, rather than none. */
|
|
44
|
+
labelling: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function usePinActions(
|
|
48
|
+
store: KycStore,
|
|
49
|
+
setError: (message: string | null) => void,
|
|
50
|
+
): PinActions {
|
|
51
|
+
const [fix, setFix] = useState<CurrentFix | null>(cachedFix());
|
|
52
|
+
const [fixPending, setFixPending] = useState(fixInFlight());
|
|
53
|
+
|
|
54
|
+
const fixSource = useMemo(() => fixSourceFor(store), [store]);
|
|
55
|
+
|
|
56
|
+
const adoptGeocodedCountry = useCallback(
|
|
57
|
+
(country: string | null | undefined, opts?: { explicit?: boolean }) => {
|
|
58
|
+
const st = store.getState();
|
|
59
|
+
const decision = adoptionDecision({
|
|
60
|
+
country,
|
|
61
|
+
selectedCountry: st.selectedCountry,
|
|
62
|
+
countryAutoPicked: st.countryAutoPicked,
|
|
63
|
+
scope: configScope(st.config),
|
|
64
|
+
accepted: st.config.proofOfAddress?.countries,
|
|
65
|
+
explicit: opts?.explicit,
|
|
66
|
+
});
|
|
67
|
+
if (!decision) return;
|
|
68
|
+
if (decision.auto) st.setCountryAuto(decision.country);
|
|
69
|
+
else st.setCountry(decision.country);
|
|
70
|
+
},
|
|
71
|
+
[store],
|
|
72
|
+
);
|
|
73
|
+
const { labelPin, labelling } = useLabelPin(store, adoptGeocodedCountry);
|
|
74
|
+
|
|
75
|
+
// The fix may have resolved on an earlier step (it is module-level): a
|
|
76
|
+
// guessed declaration is corrected on this mount too, not only when the
|
|
77
|
+
// prefetch happens to finish here.
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
adoptGeocodedCountry(cachedFix()?.parts?.country);
|
|
80
|
+
}, [adoptGeocodedCountry]);
|
|
81
|
+
|
|
82
|
+
// ── The shared current-location fix ───────────────────────────────────────
|
|
83
|
+
const startPrefetch = useCallback(() => {
|
|
84
|
+
setFixPending(true);
|
|
85
|
+
void prefetchCurrentFix(fixSource).then((f) => {
|
|
86
|
+
setFix(f);
|
|
87
|
+
setFixPending(false);
|
|
88
|
+
adoptGeocodedCountry(f?.parts?.country);
|
|
89
|
+
});
|
|
90
|
+
}, [adoptGeocodedCountry, fixSource]);
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Land the pin ON the current fix — the bootstrap override, used while no
|
|
94
|
+
* address exists yet. It sets NO `pickedAt`, so the resulting label is
|
|
95
|
+
* treated as derived and re-derives freely on the next move.
|
|
96
|
+
*/
|
|
97
|
+
const applyCurrentFix = useCallback(
|
|
98
|
+
async (onDone?: () => void, opts?: { silent?: boolean }) => {
|
|
99
|
+
setError(null);
|
|
100
|
+
setFixPending(true);
|
|
101
|
+
// A TAP may retry a previously failed attempt (permission may have been
|
|
102
|
+
// granted since); the silent auto path never re-prompts.
|
|
103
|
+
const f = await prefetchCurrentFix(fixSource, { retry: !opts?.silent });
|
|
104
|
+
setFix(f);
|
|
105
|
+
setFixPending(false);
|
|
106
|
+
adoptGeocodedCountry(f?.parts?.country);
|
|
107
|
+
// The fix can take up to eight seconds, and "Skip for now" is a decision
|
|
108
|
+
// the applicant may make inside that window. A late fix writing an
|
|
109
|
+
// address they declined to give (and an onDone navigating them back onto
|
|
110
|
+
// a step they just left) undoes that decision, so a continuation whose
|
|
111
|
+
// step is no longer in the address flow drops everything on the floor.
|
|
112
|
+
if (!isAddressStep(store.getState().currentStep)) return;
|
|
113
|
+
const cur = store.getState().address;
|
|
114
|
+
// The silent apply resolves seconds after it started, and the person may
|
|
115
|
+
// have picked a searched address meanwhile. A late GPS fix must never
|
|
116
|
+
// overwrite a choice they made; an explicit tap still overrides.
|
|
117
|
+
if (opts?.silent && cur) {
|
|
118
|
+
onDone?.();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (f) {
|
|
122
|
+
store.getState().setAddress(fixApplied(cur, f));
|
|
123
|
+
} else if (!opts?.silent) {
|
|
124
|
+
// Which remedy the person needs depends on WHY the read failed.
|
|
125
|
+
setError(locationFailureMessage(currentFixFailure()));
|
|
126
|
+
}
|
|
127
|
+
onDone?.();
|
|
128
|
+
},
|
|
129
|
+
[adoptGeocodedCountry, fixSource, setError, store],
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const setPin = useCallback(
|
|
133
|
+
(next: { lat: number; lng: number }, accuracy: number | null = null) => {
|
|
134
|
+
const move = pinMove(store.getState().address, next, accuracy);
|
|
135
|
+
if (move.kind === 'ignore') return;
|
|
136
|
+
store.getState().setAddress(move.address);
|
|
137
|
+
// A kept label needs no reverse geocode: it already names the property.
|
|
138
|
+
if (move.kind === 'rebuild') labelPin(next.lat, next.lng);
|
|
139
|
+
},
|
|
140
|
+
[labelPin, store],
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const keepPickedLabel = useCallback(() => {
|
|
144
|
+
const cur = store.getState().address;
|
|
145
|
+
if (cur) store.getState().setAddress({ ...cur, labelKept: true });
|
|
146
|
+
}, [store]);
|
|
147
|
+
|
|
148
|
+
/** The applicant adopts the PIN's address: drop the pick, re-derive. */
|
|
149
|
+
const adoptPinAddress = useCallback(() => {
|
|
150
|
+
const cur = store.getState().address;
|
|
151
|
+
if (!cur) return;
|
|
152
|
+
store.getState().setAddress(withoutPickedLabel(cur));
|
|
153
|
+
labelPin(cur.lat, cur.lng, 0);
|
|
154
|
+
}, [labelPin, store]);
|
|
155
|
+
|
|
156
|
+
/** Reverse-geocode the current pin's line — for a session restored from a
|
|
157
|
+
* snapshot saved before labels existed, which would show raw coordinates. */
|
|
158
|
+
const relabelPin = useCallback(() => {
|
|
159
|
+
const cur = store.getState().address;
|
|
160
|
+
if (cur && !cur.label) labelPin(cur.lat, cur.lng, 0);
|
|
161
|
+
}, [labelPin, store]);
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The on-map locate control: moves the PIN through `setPin`, so a picked
|
|
165
|
+
* address is protected by the same keep/update prompt as any other pin move
|
|
166
|
+
* and a mistaken tap destroys nothing.
|
|
167
|
+
*/
|
|
168
|
+
const locateToPin = useCallback(async () => {
|
|
169
|
+
setError(null);
|
|
170
|
+
setFixPending(true);
|
|
171
|
+
const f = await prefetchCurrentFix(fixSource, { retry: true });
|
|
172
|
+
setFix(f);
|
|
173
|
+
setFixPending(false);
|
|
174
|
+
adoptGeocodedCountry(f?.parts?.country);
|
|
175
|
+
if (f) setPin({ lat: f.lat, lng: f.lng }, f.accuracy);
|
|
176
|
+
else setError(locationFailureMessage(currentFixFailure()));
|
|
177
|
+
}, [adoptGeocodedCountry, fixSource, setError, setPin]);
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
labelling,
|
|
181
|
+
currentFix: fix,
|
|
182
|
+
locating: fixPending,
|
|
183
|
+
startPrefetch,
|
|
184
|
+
applyCurrentFix,
|
|
185
|
+
setPin,
|
|
186
|
+
keepPickedLabel,
|
|
187
|
+
adoptPinAddress,
|
|
188
|
+
relabelPin,
|
|
189
|
+
locateToPin,
|
|
190
|
+
adoptGeocodedCountry,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { ActivityIndicator, Pressable, View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { spacing } from '../../config/theme';
|
|
5
|
+
import { useKyc, useKycConfig, useKycStore, useTheme } from '../../components/runtime';
|
|
6
|
+
import { KycSheet } from '../../components/KycSheet';
|
|
7
|
+
import { ToastProvider } from '../../components/toast';
|
|
8
|
+
import { MyazaText } from '../../components/Typography';
|
|
9
|
+
import { MyazaButton } from '../../components/MyazaButton';
|
|
10
|
+
import { Icon } from '../../components/Icon';
|
|
11
|
+
import { LivenessStep } from '../LivenessStep';
|
|
12
|
+
import { mapAuthError, outcomeOf, type BiometricAuthOutcome } from '../../lib/biometric-auth';
|
|
13
|
+
import type { KYCError } from '../../types/verification';
|
|
14
|
+
import type { BiometricAuthResponse } from '../../services/api-types-biometric';
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// The re-auth flow inside its own runtime: intro → the REAL liveness step →
|
|
18
|
+
// authenticating → result. The liveness step is the ordinary one — camera,
|
|
19
|
+
// challenges, capture ring, selfie upload — mounted alone with the store
|
|
20
|
+
// parked on 'liveness'. Its Continue advances the store to 'submitted' (the
|
|
21
|
+
// scope's step order), which is the hand-over: the uploaded selfie's mediaId
|
|
22
|
+
// is read off the store and sent to /biometric/authenticate. The submitted
|
|
23
|
+
// screen is never rendered, so nothing ever calls /verify.
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
type View_ = 'intro' | 'capture' | 'authenticating' | 'result';
|
|
27
|
+
|
|
28
|
+
export function BiometricAuthFlow({
|
|
29
|
+
externalUserId,
|
|
30
|
+
onAuthenticated,
|
|
31
|
+
onFailed,
|
|
32
|
+
onError,
|
|
33
|
+
onClose,
|
|
34
|
+
}: {
|
|
35
|
+
externalUserId: string;
|
|
36
|
+
onAuthenticated?: (result: BiometricAuthResponse) => void;
|
|
37
|
+
onFailed?: (result: BiometricAuthResponse) => void;
|
|
38
|
+
onError?: (error: KYCError) => void;
|
|
39
|
+
onClose: () => void;
|
|
40
|
+
}): React.ReactElement {
|
|
41
|
+
const config = useKycConfig();
|
|
42
|
+
const store = useKycStore();
|
|
43
|
+
const currentStep = useKyc((s) => s.currentStep);
|
|
44
|
+
const selfieId = useKyc((s) => s.mediaIds.selfie);
|
|
45
|
+
const immersive = useKyc((s) => s.immersiveCapture);
|
|
46
|
+
const [view, setView] = useState<View_>('intro');
|
|
47
|
+
const [outcome, setOutcome] = useState<BiometricAuthOutcome | null>(null);
|
|
48
|
+
const inFlight = useRef(false);
|
|
49
|
+
|
|
50
|
+
const startCapture = () => {
|
|
51
|
+
store.setState({ currentStep: 'liveness', mediaIds: {}, selfiePreviewUri: null });
|
|
52
|
+
setOutcome(null);
|
|
53
|
+
setView('capture');
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// The hand-over: the step's Continue moved the store past 'liveness' with
|
|
57
|
+
// the selfie uploaded.
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (view !== 'capture' || currentStep === 'liveness' || !selfieId || inFlight.current) return;
|
|
60
|
+
inFlight.current = true;
|
|
61
|
+
setView('authenticating');
|
|
62
|
+
const mode = config.livenessMode ?? 'gestures';
|
|
63
|
+
store
|
|
64
|
+
.getState()
|
|
65
|
+
.api.authenticate({ externalUserId, selfie: selfieId, liveness: { mode, passed: true } })
|
|
66
|
+
.then((result) => {
|
|
67
|
+
const next = outcomeOf(result);
|
|
68
|
+
setOutcome(next);
|
|
69
|
+
if (next.kind === 'success') onAuthenticated?.(result);
|
|
70
|
+
else onFailed?.(result);
|
|
71
|
+
})
|
|
72
|
+
.catch((err: unknown) => {
|
|
73
|
+
const kyc = mapAuthError(err);
|
|
74
|
+
setOutcome({ kind: 'error', message: kyc.message });
|
|
75
|
+
onError?.(kyc);
|
|
76
|
+
})
|
|
77
|
+
.finally(() => {
|
|
78
|
+
inFlight.current = false;
|
|
79
|
+
setView('result');
|
|
80
|
+
});
|
|
81
|
+
}, [view, currentStep, selfieId, config.livenessMode, externalUserId, store, onAuthenticated, onFailed, onError]);
|
|
82
|
+
|
|
83
|
+
const title =
|
|
84
|
+
view === 'capture' ? 'Face check' : view === 'authenticating' ? "Verifying it's you" : "Verify it's you";
|
|
85
|
+
const dismissBlocked = config.disableClose === true || view === 'authenticating';
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<ToastProvider>
|
|
89
|
+
<KycSheet
|
|
90
|
+
title={title}
|
|
91
|
+
description={view === 'capture' ? 'Follow the prompts, then hold still for the photo.' : null}
|
|
92
|
+
progress={null}
|
|
93
|
+
stepCount={null}
|
|
94
|
+
onBack={view === 'capture' ? () => setView('intro') : null}
|
|
95
|
+
onClose={dismissBlocked ? () => undefined : onClose}
|
|
96
|
+
immersive={immersive}
|
|
97
|
+
>
|
|
98
|
+
{view === 'intro' && <Intro onStart={startCapture} />}
|
|
99
|
+
{view === 'capture' && <LivenessStep />}
|
|
100
|
+
{view === 'authenticating' && <Authenticating />}
|
|
101
|
+
{view === 'result' && outcome && <Result outcome={outcome} onRetry={startCapture} onClose={onClose} />}
|
|
102
|
+
</KycSheet>
|
|
103
|
+
</ToastProvider>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function Intro({ onStart }: { onStart: () => void }): React.ReactElement {
|
|
108
|
+
const { colors } = useTheme();
|
|
109
|
+
return (
|
|
110
|
+
<View style={{ alignItems: 'center', paddingVertical: spacing.lg }}>
|
|
111
|
+
<View style={{ width: 80, height: 80, borderRadius: 40, backgroundColor: `${colors.primary}1A`, alignItems: 'center', justifyContent: 'center' }}>
|
|
112
|
+
<Icon name="user" size={36} color={colors.primary} />
|
|
113
|
+
</View>
|
|
114
|
+
<View style={{ height: spacing.lg }} />
|
|
115
|
+
<MyazaText variant="body" style={{ textAlign: 'center' }} color={colors.textSecondary}>
|
|
116
|
+
We'll take a quick face check to confirm your identity. No documents needed.
|
|
117
|
+
</MyazaText>
|
|
118
|
+
<View style={{ height: spacing.xl }} />
|
|
119
|
+
<MyazaButton label="Start" onPress={onStart} />
|
|
120
|
+
</View>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function Authenticating(): React.ReactElement {
|
|
125
|
+
const { colors } = useTheme();
|
|
126
|
+
return (
|
|
127
|
+
<View style={{ alignItems: 'center', paddingVertical: spacing.xl * 2 }}>
|
|
128
|
+
<ActivityIndicator size="large" color={colors.primary} />
|
|
129
|
+
<View style={{ height: spacing.lg }} />
|
|
130
|
+
<MyazaText variant="body" style={{ fontWeight: '600' }}>Verifying it's you…</MyazaText>
|
|
131
|
+
<MyazaText variant="bodySmall" color={colors.textSecondary}>This only takes a moment.</MyazaText>
|
|
132
|
+
</View>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function Result({ outcome, onRetry, onClose }: { outcome: BiometricAuthOutcome; onRetry: () => void; onClose: () => void }): React.ReactElement {
|
|
137
|
+
const { colors } = useTheme();
|
|
138
|
+
const ok = outcome.kind === 'success';
|
|
139
|
+
const tint = ok ? colors.success : colors.error;
|
|
140
|
+
const heading = ok ? "You're verified" : outcome.kind === 'failed' ? "Couldn't verify you" : 'Something went wrong';
|
|
141
|
+
const detail = ok
|
|
142
|
+
? "We confirmed it's really you."
|
|
143
|
+
: outcome.kind === 'failed'
|
|
144
|
+
? "We couldn't confirm it's you. Make sure your face is clear and well lit, then try again."
|
|
145
|
+
: outcome.message;
|
|
146
|
+
return (
|
|
147
|
+
<View style={{ alignItems: 'center', paddingVertical: spacing.lg }}>
|
|
148
|
+
<View style={{ width: 80, height: 80, borderRadius: 40, backgroundColor: `${tint}1A`, alignItems: 'center', justifyContent: 'center' }}>
|
|
149
|
+
{/* The web's result: a check, or the alert circle the flow's own
|
|
150
|
+
verdict screen uses (never a bare X), under a heading-font title. */}
|
|
151
|
+
<Icon name={ok ? 'check' : 'alert'} size={36} color={tint} />
|
|
152
|
+
</View>
|
|
153
|
+
<View style={{ height: spacing.lg }} />
|
|
154
|
+
<MyazaText variant="heading2" style={{ textAlign: 'center' }}>{heading}</MyazaText>
|
|
155
|
+
<View style={{ height: spacing.xs }} />
|
|
156
|
+
<MyazaText variant="body" color={colors.textSecondary} style={{ textAlign: 'center' }}>{detail}</MyazaText>
|
|
157
|
+
<View style={{ height: spacing.xl }} />
|
|
158
|
+
{ok ? (
|
|
159
|
+
<MyazaButton label="Done" onPress={onClose} />
|
|
160
|
+
) : (
|
|
161
|
+
<>
|
|
162
|
+
<MyazaButton label="Try again" onPress={onRetry} />
|
|
163
|
+
<Pressable onPress={onClose} accessibilityRole="button" hitSlop={8} style={{ paddingVertical: spacing.md }}>
|
|
164
|
+
<MyazaText variant="body" color={colors.textSecondary}>Close</MyazaText>
|
|
165
|
+
</Pressable>
|
|
166
|
+
</>
|
|
167
|
+
)}
|
|
168
|
+
</View>
|
|
169
|
+
);
|
|
170
|
+
}
|