@myazahq/kyc-sdk-react-native 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/README.md +151 -5
  2. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +6 -4
  3. package/app.plugin.js +89 -8
  4. package/ios/HybridMyazaFaceDetector.swift +8 -3
  5. package/package.json +14 -2
  6. package/src/MyazaBiometricAuth.tsx +119 -0
  7. package/src/components/CountryField.tsx +8 -0
  8. package/src/components/CountryRegionPicker.tsx +184 -0
  9. package/src/components/DialCodePicker.tsx +47 -68
  10. package/src/components/DialCodeRow.tsx +102 -0
  11. package/src/components/FramedMapPicker.tsx +122 -0
  12. package/src/components/GeoBadge.tsx +34 -0
  13. package/src/components/Icon.tsx +2 -185
  14. package/src/components/KycFlow.tsx +60 -32
  15. package/src/components/LineSkeleton.tsx +100 -0
  16. package/src/components/MapChrome.tsx +67 -0
  17. package/src/components/MapPinMarker.tsx +54 -0
  18. package/src/components/MapPinPicker.tsx +179 -0
  19. package/src/components/MilestoneTrack.tsx +155 -0
  20. package/src/components/MyazaInput.tsx +17 -1
  21. package/src/components/OptionRow.tsx +22 -3
  22. package/src/components/PhoneNumberInput.tsx +8 -0
  23. package/src/components/PoweredBy.tsx +5 -5
  24. package/src/components/PresenceBlocks.tsx +153 -0
  25. package/src/components/StepView.tsx +17 -0
  26. package/src/components/StickyActions.tsx +56 -0
  27. package/src/components/dialCodeRows.ts +64 -0
  28. package/src/components/icon-map.ts +176 -0
  29. package/src/components/stepHeaderMeta.tsx +42 -4
  30. package/src/config/addressCollection.ts +125 -0
  31. package/src/config/biometricOptions.ts +100 -0
  32. package/src/config/business.ts +20 -1
  33. package/src/config/businessSteps.ts +8 -2
  34. package/src/config/consentStep.ts +19 -0
  35. package/src/config/proofOfAddress.ts +54 -11
  36. package/src/config/regions.ts +24 -0
  37. package/src/config/stepOrder.ts +79 -5
  38. package/src/config/uploadLimits.ts +35 -0
  39. package/src/config/workflowMerge.ts +11 -5
  40. package/src/index.ts +54 -1
  41. package/src/lib/address-current-location.ts +171 -0
  42. package/src/lib/address-field-modes.ts +119 -0
  43. package/src/lib/address-flow.ts +172 -0
  44. package/src/lib/address-helpers.ts +48 -0
  45. package/src/lib/address-line.ts +76 -0
  46. package/src/lib/address-pin-move.ts +97 -0
  47. package/src/lib/address-step-recovery.ts +63 -0
  48. package/src/lib/authed-image.ts +78 -0
  49. package/src/lib/biometric-auth.ts +52 -0
  50. package/src/lib/biometric-copy.ts +51 -0
  51. package/src/lib/captureRing.ts +83 -0
  52. package/src/lib/country-adoption.ts +89 -0
  53. package/src/lib/inferred-country.ts +65 -0
  54. package/src/lib/livenessLayout.ts +49 -0
  55. package/src/lib/map-frame.ts +159 -0
  56. package/src/lib/map-tiles.ts +143 -0
  57. package/src/lib/poa-country-gate.ts +27 -0
  58. package/src/lib/result-copy.ts +116 -0
  59. package/src/lib/result-wait.ts +53 -0
  60. package/src/lib/review-map-surface.ts +26 -0
  61. package/src/lib/scope.ts +31 -0
  62. package/src/lib/selfie-upload-wait.ts +79 -0
  63. package/src/lib/street-view-fov.ts +42 -0
  64. package/src/lib/webview-available.ts +47 -0
  65. package/src/liveness/useLiveness.ts +6 -1
  66. package/src/presence/background-math.ts +100 -0
  67. package/src/presence/background-store.ts +82 -0
  68. package/src/presence/background.ts +165 -0
  69. package/src/presence/foreground-service.ts +193 -0
  70. package/src/presence/fs.ts +51 -0
  71. package/src/presence/geofence.ts +41 -0
  72. package/src/presence/math.ts +44 -0
  73. package/src/presence/post.ts +40 -0
  74. package/src/presence/report.ts +87 -0
  75. package/src/presence/sampler.ts +110 -0
  76. package/src/presence/status.ts +92 -0
  77. package/src/presence/store.ts +94 -0
  78. package/src/presence/tier.ts +36 -0
  79. package/src/presence/watch-wait.ts +81 -0
  80. package/src/screens/AddressCountryControl.tsx +115 -0
  81. package/src/screens/BusinessDetailsFields.tsx +3 -0
  82. package/src/screens/BusinessDetailsStep.tsx +2 -0
  83. package/src/screens/BusinessDocumentSlot.tsx +3 -2
  84. package/src/screens/BusinessDocumentsStep.tsx +4 -55
  85. package/src/screens/CompanyInfoFields.tsx +6 -1
  86. package/src/screens/ConsentStep.tsx +6 -17
  87. package/src/screens/ContactActions.tsx +54 -0
  88. package/src/screens/ContactDestinationField.tsx +4 -0
  89. package/src/screens/ContactEntryPanel.tsx +75 -0
  90. package/src/screens/ContactVerificationStep.tsx +26 -56
  91. package/src/screens/CountrySelectStep.tsx +13 -147
  92. package/src/screens/LivenessAvatar.tsx +15 -3
  93. package/src/screens/LivenessStep.tsx +154 -52
  94. package/src/screens/PoaDocumentTypeList.tsx +66 -0
  95. package/src/screens/ProofOfAddressParts.tsx +151 -0
  96. package/src/screens/ProofOfAddressStep.tsx +58 -99
  97. package/src/screens/SubmittedBadge.tsx +25 -0
  98. package/src/screens/SubmittedError.tsx +64 -0
  99. package/src/screens/SubmittedResult.tsx +117 -0
  100. package/src/screens/SubmittedStep.tsx +71 -164
  101. package/src/screens/SubmittedSuccess.tsx +127 -0
  102. package/src/screens/SubmittedWaiting.tsx +45 -0
  103. package/src/screens/address/AddressEntranceStep.tsx +161 -0
  104. package/src/screens/address/AddressIntroGate.tsx +148 -0
  105. package/src/screens/address/AddressMapStub.tsx +50 -0
  106. package/src/screens/address/AddressPinStep.tsx +178 -0
  107. package/src/screens/address/AddressReviewStep.tsx +162 -0
  108. package/src/screens/address/AddressSandboxTabs.tsx +158 -0
  109. package/src/screens/address/AddressSearchField.tsx +115 -0
  110. package/src/screens/address/AddressSearchStep.tsx +94 -0
  111. package/src/screens/address/CurrentLocationRow.tsx +135 -0
  112. package/src/screens/address/DetailsSheet.tsx +175 -0
  113. package/src/screens/address/DetailsSheetFields.tsx +184 -0
  114. package/src/screens/address/EntranceDropzone.tsx +178 -0
  115. package/src/screens/address/EntranceFraming.tsx +66 -0
  116. package/src/screens/address/EntrancePills.tsx +60 -0
  117. package/src/screens/address/FramedStreetView.tsx +155 -0
  118. package/src/screens/address/IntroDisclosures.tsx +210 -0
  119. package/src/screens/address/LabelDecisionRow.tsx +113 -0
  120. package/src/screens/address/PinSummaryRow.tsx +105 -0
  121. package/src/screens/address/ReviewAddressBand.tsx +111 -0
  122. package/src/screens/address/ReviewEntranceThumbs.tsx +64 -0
  123. package/src/screens/address/ReviewMapPicture.tsx +106 -0
  124. package/src/screens/address/SearchResults.tsx +105 -0
  125. package/src/screens/address/SearchScreen.tsx +176 -0
  126. package/src/screens/address/SkipForNow.tsx +43 -0
  127. package/src/screens/address/StreetViewChrome.tsx +81 -0
  128. package/src/screens/address/detail-values.ts +22 -0
  129. package/src/screens/address/fix-source.ts +27 -0
  130. package/src/screens/address/index.ts +8 -0
  131. package/src/screens/address/meta.ts +44 -0
  132. package/src/screens/address/use-address-flow.ts +200 -0
  133. package/src/screens/address/use-label-pin.ts +80 -0
  134. package/src/screens/address/use-pin-actions.ts +192 -0
  135. package/src/screens/biometric/BiometricAuthFlow.tsx +170 -0
  136. package/src/screens/consent/model.ts +79 -10
  137. package/src/screens/liveness/CaptureRing.tsx +91 -0
  138. package/src/screens/liveness/LivenessCamera.tsx +63 -0
  139. package/src/screens/liveness/LivenessHandover.tsx +43 -0
  140. package/src/screens/liveness/LivenessOutcome.tsx +4 -3
  141. package/src/screens/liveness/SelfiePreview.tsx +13 -4
  142. package/src/screens/liveness/ShutterFlash.tsx +25 -0
  143. package/src/screens/liveness/index.ts +4 -0
  144. package/src/screens/liveness/useSelfieUpload.ts +23 -3
  145. package/src/screens/nfc/NfcScanIllustration.tsx +7 -18
  146. package/src/screens/useAddressPhotoAttach.ts +77 -0
  147. package/src/screens/useBusinessDocumentAttach.ts +93 -0
  148. package/src/screens/usePoaAttach.ts +12 -15
  149. package/src/services/api-biometric.ts +37 -0
  150. package/src/services/api-types-biometric.ts +41 -0
  151. package/src/services/api-types.ts +93 -1
  152. package/src/services/api-verify-types.ts +37 -0
  153. package/src/services/api.ts +133 -3
  154. package/src/services/deviceMetadata.ts +1 -1
  155. package/src/services/location.ts +234 -0
  156. package/src/services/uploadErrors.ts +30 -0
  157. package/src/services/workflowGate.ts +7 -0
  158. package/src/store/address.ts +97 -0
  159. package/src/store/derive.ts +29 -4
  160. package/src/store/kycStore.ts +112 -3
  161. package/src/store/serverConfig.ts +18 -0
  162. package/src/store/session.ts +23 -4
  163. package/src/store/state.ts +134 -0
  164. package/src/store/submit.ts +18 -1
  165. package/src/types/config.ts +48 -1
  166. package/src/types/verification.ts +12 -0
  167. package/src/types/workflow.ts +97 -1
@@ -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
+ }