@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,119 @@
1
+ import type { AddressCollectionConfig } from '../types/workflow';
2
+ import type { AddressState } from '../store/state';
3
+
4
+ // Per-field modes for the typed details-sheet fields — the CLIENT MIRROR of
5
+ // the server's lib/workflows/address-fields.ts (kyc-core) and of the web
6
+ // SDK's steps/address/address-field-modes.ts. Keep the resolution rule in
7
+ // lockstep: the legacy propertyFields group switch is the default for every
8
+ // typed field, propertyFields 'required' requires the house/flat NUMBER, and
9
+ // fields.<key> overrides per key. The three mirrors share one vector file
10
+ // (kyc-sdk-flutter/test/address_field_modes_vectors.json).
11
+ //
12
+ // Why it matters here: the server 422s a submission whose required fields
13
+ // never arrived. Without this mirror a mobile applicant walked the whole
14
+ // flow, and the refusal landed on the last screen with nothing to act on.
15
+
16
+ export const ADDRESS_FIELD_KEYS = [
17
+ 'propertyName',
18
+ 'propertyNumber',
19
+ 'street',
20
+ 'unit',
21
+ 'neighbourhood',
22
+ 'city',
23
+ 'state',
24
+ 'postcode',
25
+ ] as const;
26
+ export type AddressFieldKey = (typeof ADDRESS_FIELD_KEYS)[number];
27
+ export type AddressFieldMode = 'off' | 'optional' | 'required';
28
+
29
+ export const ADDRESS_FIELD_LABELS: Record<AddressFieldKey, string> = {
30
+ propertyName: 'Building name',
31
+ propertyNumber: 'House or flat number',
32
+ street: 'Street name',
33
+ unit: 'Unit',
34
+ neighbourhood: 'Neighbourhood',
35
+ city: 'City',
36
+ state: 'State',
37
+ postcode: 'Area code',
38
+ };
39
+
40
+ const MODES: readonly string[] = ['off', 'optional', 'required'];
41
+ function isAddressFieldMode(value: unknown): value is AddressFieldMode {
42
+ return typeof value === 'string' && MODES.includes(value);
43
+ }
44
+
45
+ export function addressFieldModes(
46
+ config: AddressCollectionConfig | undefined | null,
47
+ ): Record<AddressFieldKey, AddressFieldMode> {
48
+ const group = config?.propertyFields ?? 'optional';
49
+ const modes = {} as Record<AddressFieldKey, AddressFieldMode>;
50
+ for (const key of ADDRESS_FIELD_KEYS) {
51
+ const fallback: AddressFieldMode =
52
+ group === 'off' ? 'off' : group === 'required' && key === 'propertyNumber' ? 'required' : 'optional';
53
+ // An override outside the vocabulary falls back rather than being taken
54
+ // literally: the server validates writes, the client validates reads.
55
+ const override = config?.fields?.[key];
56
+ modes[key] = isAddressFieldMode(override) ? override : fallback;
57
+ }
58
+ return modes;
59
+ }
60
+
61
+ /** Which map-prefill part fills each field while untouched (the sheet's own
62
+ * `shown` rule). Property name/number and unit have no prefill — the
63
+ * applicant alone can know them. */
64
+ const PART_OF: Partial<Record<AddressFieldKey, 'street' | 'area' | 'city' | 'state' | 'postcode'>> = {
65
+ street: 'street',
66
+ neighbourhood: 'area',
67
+ city: 'city',
68
+ state: 'state',
69
+ postcode: 'postcode',
70
+ };
71
+
72
+ /** The value the sheet DISPLAYS for a field: typed wins (a cleared field
73
+ * stays cleared), else the map's prefill. */
74
+ export function displayedAddressValue(key: AddressFieldKey, address: AddressState): string {
75
+ const typed = (address[key] as string | undefined)?.trim();
76
+ if (typed !== undefined) return typed;
77
+ const part = PART_OF[key];
78
+ return (part ? address.parts?.[part] : null)?.trim() ?? '';
79
+ }
80
+
81
+ /** Required fields whose DISPLAYED value is blank — what holds Continue. */
82
+ export function missingRequiredAddressFields(
83
+ config: AddressCollectionConfig | undefined | null,
84
+ address: AddressState | null | undefined,
85
+ ): AddressFieldKey[] {
86
+ if (!address) return [];
87
+ const modes = addressFieldModes(config);
88
+ return ADDRESS_FIELD_KEYS.filter(
89
+ (key) => modes[key] === 'required' && displayedAddressValue(key, address) === '',
90
+ );
91
+ }
92
+
93
+ /** The nudge under Continue when required fields are still blank. */
94
+ export function missingFieldsNudge(missing: AddressFieldKey[]): string {
95
+ return `This flow needs: ${missing.map((k) => ADDRESS_FIELD_LABELS[k].toLowerCase()).join(', ')}.`;
96
+ }
97
+
98
+ /**
99
+ * The map-prefill values a REQUIRED field rides to the server when the
100
+ * applicant left it untouched: they saw it filled and confirmed it by
101
+ * continuing, so the wire must carry it — the server 422s a required field
102
+ * that never arrives. Typed values are absent here on purpose (the normal
103
+ * assembly already sends them), so spreading this LAST overrides nothing.
104
+ */
105
+ export function requiredPrefillSubmission(
106
+ config: AddressCollectionConfig | undefined | null,
107
+ address: AddressState,
108
+ ): Partial<Record<AddressFieldKey, string>> {
109
+ const modes = addressFieldModes(config);
110
+ const out: Partial<Record<AddressFieldKey, string>> = {};
111
+ for (const key of ADDRESS_FIELD_KEYS) {
112
+ if (modes[key] !== 'required') continue;
113
+ const typed = (address[key] as string | undefined)?.trim();
114
+ if (typed) continue;
115
+ const displayed = displayedAddressValue(key, address);
116
+ if (displayed) out[key] = displayed;
117
+ }
118
+ return out;
119
+ }
@@ -0,0 +1,172 @@
1
+ import type { KYCStep } from '../types/config';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // The address flow as REAL steps: find it (search) → confirm it (pin, with the
5
+ // details sheet) → show it (entrance) → commit it (review + attest fix). The
6
+ // progress bar advances through them and back/forward is ordinary step
7
+ // navigation, not a machine hidden inside one screen. KYB keeps the single
8
+ // premises step — its pin + directions ARE the capture, and the KYB flow
9
+ // already has its own section rhythm.
10
+ //
11
+ // 'address-collection' is the PIN step and keeps the original wire name, so
12
+ // session progress saved by older builds restores cleanly and the server's
13
+ // step-log titles stay meaningful. Do not "fix" it to 'address-pin'.
14
+ //
15
+ // A MIRROR of the web SDK's steps/address/flow-steps.ts and the Flutter port —
16
+ // the same numbers and the same decisions, or one platform strands an
17
+ // applicant the others do not. Keep all three in lockstep.
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /**
21
+ * SANDBOX mirrors the web SDK's stubbed vendor treatment (user decision
22
+ * 2026-09-03): placeholder map, canned labels, no search loads — the server's
23
+ * sandbox verdicts are canned anyway, so live vendor calls on test keys spend
24
+ * quota for nothing. DEVELOPMENT keeps the real surfaces (the platform's
25
+ * dev-is-real rule); an unknown environment counts as live.
26
+ */
27
+ export function addressVendorsStubbed(facts: { environment?: string | null }): boolean {
28
+ return facts.environment === 'SANDBOX';
29
+ }
30
+
31
+ /** The canned pin label every stubbed surface shows — obviously a sample,
32
+ * never a real place. Keep in lockstep with the web SDK. */
33
+ export const SAMPLE_ADDRESS_LINE = '12 Sample Street, Sample City';
34
+
35
+ export interface AddressFlowOptions {
36
+ /** A search backend is available AND we are not in builder preview. */
37
+ searchAvailable: boolean;
38
+ /** The entrance-photo mode ('off' hides that capture). */
39
+ photoMode: 'off' | 'optional' | 'required';
40
+ /** Street View framing is offered (workflow on + a browser key in-document). */
41
+ streetViewOffered: boolean;
42
+ }
43
+
44
+ /** The individual flow's address steps, in order. */
45
+ export function addressFlowSteps(o: AddressFlowOptions): KYCStep[] {
46
+ const steps: KYCStep[] = [];
47
+ if (o.searchAvailable) steps.push('address-search');
48
+ steps.push('address-collection');
49
+ if (o.photoMode !== 'off' || o.streetViewOffered) steps.push('address-entrance');
50
+ steps.push('address-review');
51
+ return steps;
52
+ }
53
+
54
+ const ADDRESS_STEPS: ReadonlySet<KYCStep> = new Set<KYCStep>([
55
+ 'address-search',
56
+ 'address-collection',
57
+ 'address-entrance',
58
+ 'address-review',
59
+ ]);
60
+
61
+ /** Whether `step` belongs to the address flow. */
62
+ export function isAddressStep(step: KYCStep): boolean {
63
+ return ADDRESS_STEPS.has(step);
64
+ }
65
+
66
+ export function nextAddressStep(steps: KYCStep[], current: KYCStep): KYCStep | null {
67
+ const i = steps.indexOf(current);
68
+ return i >= 0 && i + 1 < steps.length ? steps[i + 1]! : null;
69
+ }
70
+
71
+ export function prevAddressStep(steps: KYCStep[], current: KYCStep): KYCStep | null {
72
+ const i = steps.indexOf(current);
73
+ return i > 0 ? steps[i - 1]! : null;
74
+ }
75
+
76
+ /**
77
+ * Derive the flow options from raw config facts — ONE place, read by the step
78
+ * hook AND the flow-order builder, so the two can never disagree about which
79
+ * screens exist. A mirror of the web SDK's addressFlowOptions.
80
+ *
81
+ * `hasGoogleKey` is always false on mobile (the key describes an in-document
82
+ * browser surface). Street View reaches a phone the same way the framed map
83
+ * does: the hosted /embed/street-view page in a WebView on the app grant,
84
+ * which is `hasStreetViewFrame` — the server minted a maps frame URL AND
85
+ * `react-native-webview` is installed. Absent either, the entrance step is
86
+ * photo-only, exactly as it was before 2026-09-06.
87
+ */
88
+ export function addressFlowOptions(facts: {
89
+ photo?: 'off' | 'optional' | 'required';
90
+ streetView?: 'off' | 'optional' | 'required';
91
+ serverSearch: boolean;
92
+ previewMode: boolean;
93
+ hasGoogleKey: boolean;
94
+ /** A maps frame URL this install can render: the framed
95
+ * /embed/street-view page carries the panorama for it. */
96
+ hasStreetViewFrame: boolean;
97
+ }): AddressFlowOptions {
98
+ const photoMode = facts.photo ?? 'optional';
99
+ return {
100
+ searchAvailable: facts.serverSearch && !facts.previewMode,
101
+ photoMode,
102
+ // ON by default: offered unless the workflow opted out, and wherever a
103
+ // Google surface exists.
104
+ streetViewOffered:
105
+ facts.streetView !== 'off' && (facts.hasGoogleKey || facts.hasStreetViewFrame),
106
+ };
107
+ }
108
+
109
+ /** Great-circle metres between two points (small-distance haversine). */
110
+ export function metersBetween(
111
+ a: { lat: number; lng: number },
112
+ b: { lat: number; lng: number },
113
+ ): number {
114
+ const R = 6371000;
115
+ const toRad = (d: number) => (d * Math.PI) / 180;
116
+ const dLat = toRad(b.lat - a.lat);
117
+ const dLng = toRad(b.lng - a.lng);
118
+ const h =
119
+ Math.sin(dLat / 2) ** 2 +
120
+ Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLng / 2) ** 2;
121
+ return 2 * R * Math.asin(Math.sqrt(h));
122
+ }
123
+
124
+ /**
125
+ * A map settle within about a metre of the current pin is the tile roundtrip
126
+ * drifting, not a move. Acting on it rebuilt the address WITHOUT its label, and
127
+ * the reverse geocoder then overwrote a searched-and-picked address with the
128
+ * area line. BOTH axes must be inside the epsilon for a move to be ignored.
129
+ */
130
+ export const PIN_EPSILON = 1e-5;
131
+
132
+ /**
133
+ * How far a pin may move from the spot a label was PICKED for before the label
134
+ * stops credibly naming it. The pick names the property; the nudge refines
135
+ * where its roof is — same scale as the server's at-address tolerance. Beyond
136
+ * this, keeping the picked name would be a lie.
137
+ */
138
+ export const KEEP_PICKED_LABEL_RADIUS_M = 250;
139
+
140
+ /** Below this, a pin move is roof refinement — keep the picked label silently;
141
+ * asking would be noise. */
142
+ export const LABEL_PROMPT_MIN_MOVE_M = 25;
143
+
144
+ /** Debounce before reverse-geocoding a moved pin. */
145
+ export const REVERSE_DEBOUNCE_MS = 700;
146
+
147
+ /** Places autocomplete: debounce, and the floor below which nothing is asked. */
148
+ export const AUTOCOMPLETE_DEBOUNCE_MS = 300;
149
+ export const SEARCH_MIN_QUERY_LENGTH = 3;
150
+
151
+ /**
152
+ * Whether the pin screen should ASK "keep the selected address?" — the
153
+ * applicant decides the label's fate, never a silent discard. Ask once past the
154
+ * refinement threshold; a prior "keep" stands until the pin crosses the
155
+ * credibility radius, where setPin resets it so the question returns exactly
156
+ * once. A DERIVED label (no pickedAt anchor) is never questioned: those
157
+ * re-derive freely on every move.
158
+ */
159
+ export function shouldAskLabelDecision(address: {
160
+ lat: number;
161
+ lng: number;
162
+ label?: string;
163
+ pickedAt?: { lat: number; lng: number };
164
+ labelKept?: boolean;
165
+ }): boolean {
166
+ if (!address.pickedAt || !address.label || address.labelKept) return false;
167
+ return metersBetween(address.pickedAt, address) > LABEL_PROMPT_MIN_MOVE_M;
168
+ }
169
+
170
+ // The displayed address line lives next door (200-line rule), re-exported so
171
+ // every importer of this file keeps working.
172
+ export { displayAddressLine } from './address-line';
@@ -0,0 +1,48 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Address state shaping — the pure half of "the pin moved".
3
+ //
4
+ // Split from address-flow.ts (the step model + the label rules) per the
5
+ // 200-line rule, and a MIRROR of the web SDK's steps/address-helpers.ts.
6
+ //
7
+ // The device fixes themselves live in services/location.ts, which owns the
8
+ // expo-location calls; nothing here touches hardware.
9
+ // ---------------------------------------------------------------------------
10
+
11
+ /**
12
+ * The address state after picking a search candidate: the pin lands on the
13
+ * hit, the applicant's typed fields survive, and the house number prefills
14
+ * ONLY when they have not typed one (their word always beats the map's).
15
+ *
16
+ * The shape is FIXED on purpose. Spreading it as the new address DROPS
17
+ * `label`, `parts`, `pickedAt`, `labelKept`, `streetView` and the device
18
+ * fields, so a caller that wants one of those back has to re-add it
19
+ * explicitly rather than carrying a stale label to a new spot by accident.
20
+ */
21
+ export function pickedAddressState(
22
+ prev:
23
+ | { directions?: string; propertyName?: string; propertyNumber?: string; street?: string }
24
+ | null
25
+ | undefined,
26
+ hit: { lat: number; lng: number; houseNumber: string | null },
27
+ ): {
28
+ lat: number;
29
+ lng: number;
30
+ accuracy: null;
31
+ directions: string;
32
+ propertyName: string;
33
+ propertyNumber: string;
34
+ street: string | undefined;
35
+ } {
36
+ return {
37
+ lat: hit.lat,
38
+ lng: hit.lng,
39
+ accuracy: null,
40
+ directions: prev?.directions ?? '',
41
+ propertyName: prev?.propertyName ?? '',
42
+ propertyNumber: prev?.propertyNumber?.trim() ? prev.propertyNumber : (hit.houseNumber ?? ''),
43
+ // Undefined, not '': the details sheet reads '' as "deliberately cleared"
44
+ // and undefined as "never touched" — initialising with '' suppressed the
45
+ // resolved-street prefill forever.
46
+ street: prev?.street,
47
+ };
48
+ }
@@ -0,0 +1,76 @@
1
+ // The address line the flow SHOWS. Split from address-flow.ts (200-line
2
+ // rule); a mirror of the web SDK's displayAddressLine and the Flutter
3
+ // address_flow.dart twin. Keep the three in lockstep.
4
+
5
+ /**
6
+ * The address line the flow SHOWS (pin summary + review heading) — the client
7
+ * mirror of the server's composed-line rules, so what the applicant confirms is
8
+ * what the org later reads. A typed number REPLACES a differing picked number
9
+ * (living at 8 when only 11 was listed is not "8, 11 Bassey Street"); a typed
10
+ * street the label does not carry leads the line.
11
+ */
12
+ export function displayAddressLine(address: {
13
+ lat: number;
14
+ lng: number;
15
+ label?: string | null;
16
+ propertyNumber?: string | null;
17
+ street?: string | null;
18
+ unit?: string | null;
19
+ neighbourhood?: string | null;
20
+ city?: string | null;
21
+ state?: string | null;
22
+ postcode?: string | null;
23
+ }): string {
24
+ const number = address.propertyNumber?.trim() || null;
25
+ const typed = address.street?.trim() || null;
26
+ const label = address.label?.trim() || null;
27
+ const unit = address.unit?.trim() || null;
28
+ // Edit-details corrections ride the tail through a part-wise dedupe,
29
+ // mirroring the server's composed line: identical values vanish, a
30
+ // correction appends beside the map's own answer.
31
+ const withClaims = (parts: string[]): string => {
32
+ const out = unit ? [unit, ...parts] : [...parts];
33
+ const seen = (v: string) => out.some((p) => p.toLowerCase() === v.toLowerCase());
34
+ for (const claim of [address.neighbourhood, address.city, address.state, address.postcode]) {
35
+ const t = claim?.trim();
36
+ if (t && !seen(t)) out.push(t);
37
+ }
38
+ return out.join(', ');
39
+ };
40
+ if (!label) {
41
+ if (typed) return withClaims([number ? `${number} ${typed}` : typed]);
42
+ const claimed = withClaims([]);
43
+ if (claimed) return claimed;
44
+ // NEVER coordinates. A moved pin has no line until the reverse geocode
45
+ // answers, and "4.93240, 8.32540" is not an address: it read as one, for
46
+ // the second or so before the real line arrived. Empty means "nothing
47
+ // human-readable yet", and the caller shows that it is still coming.
48
+ return '';
49
+ }
50
+ const segs = label
51
+ .split(', ')
52
+ .map((t) => t.trim())
53
+ .filter(Boolean);
54
+ if (typed && !label.toLowerCase().includes(typed.toLowerCase())) {
55
+ return withClaims([number ? `${number} ${typed}` : typed, ...segs]);
56
+ }
57
+ if (number) {
58
+ const first = segs[0] ?? '';
59
+ const firstTokens = first.toLowerCase().split(/\s+/);
60
+ const leading = firstTokens[0] ?? '';
61
+ if (/^\d+[a-z]?$/i.test(leading) && leading !== number.toLowerCase()) {
62
+ segs[0] = [number, ...first.split(/\s+/).slice(1)].join(' ');
63
+ } else if (!firstTokens.includes(number.toLowerCase())) {
64
+ return withClaims([number, ...segs]);
65
+ }
66
+ }
67
+ return withClaims(segs);
68
+ }
69
+
70
+ /** Read to assistive tech while the reverse geocode is out: the pin has a
71
+ * line coming, and a lat/lng pair is not it. Sighted users see a skeleton
72
+ * line in its place (components/LineSkeleton), never a spinner. Mirrored on
73
+ * the web and Flutter SDKs. */
74
+ export const ADDRESS_LINE_PENDING = 'Finding the address…';
75
+ /** Shown when the geocode came back with nothing. Still not coordinates. */
76
+ export const ADDRESS_LINE_UNAVAILABLE = 'No address found for this spot';
@@ -0,0 +1,97 @@
1
+ import { pickedAddressState } from './address-helpers';
2
+ import { KEEP_PICKED_LABEL_RADIUS_M, PIN_EPSILON, metersBetween } from './address-flow';
3
+ import type { AddressState } from '../store/state';
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // What the address becomes when the pin moves.
7
+ //
8
+ // Pure, so the rules that decide whether a human-confirmed label survives can
9
+ // be read and tested without a map, a store, or a GPS. The hook does the
10
+ // dispatching; these decide.
11
+ //
12
+ // A MIRROR of the web SDK's setPin / applyCurrentFix / adoptPinAddress bodies.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ /** Where a pin move lands. `ignore` means the map settled, it did not move. */
16
+ export type PinMove =
17
+ | { kind: 'ignore' }
18
+ /** The picked label stands: only the coordinates (and the keep flag) change,
19
+ * and NO reverse geocode is wanted. */
20
+ | { kind: 'keep-label'; address: AddressState }
21
+ /** The address is rebuilt around the new spot and wants a fresh label. */
22
+ | { kind: 'rebuild'; address: AddressState };
23
+
24
+ export function pinMove(
25
+ cur: AddressState | null,
26
+ next: { lat: number; lng: number },
27
+ accuracy: number | null,
28
+ ): PinMove {
29
+ // A map settle within about a metre of the current pin is the tile roundtrip
30
+ // drifting, not a move. Acting on it rebuilt the address WITHOUT its label,
31
+ // and the reverse geocoder then overwrote a searched-and-picked address with
32
+ // the area line. BOTH axes must be inside the epsilon.
33
+ if (
34
+ cur &&
35
+ Math.abs(cur.lat - next.lat) < PIN_EPSILON &&
36
+ Math.abs(cur.lng - next.lng) < PIN_EPSILON
37
+ ) {
38
+ return { kind: 'ignore' };
39
+ }
40
+
41
+ // A PICKED label names the property; the pin refines where its roof is, so
42
+ // the label survives the move and the applicant decides its fate. Crossing
43
+ // the credibility radius only resets a prior "keep", so the question is
44
+ // asked again exactly once out there.
45
+ if (cur?.pickedAt && cur.label) {
46
+ const beyond = metersBetween(cur.pickedAt, next) > KEEP_PICKED_LABEL_RADIUS_M;
47
+ return {
48
+ kind: 'keep-label',
49
+ address: {
50
+ ...cur,
51
+ lat: next.lat,
52
+ lng: next.lng,
53
+ accuracy,
54
+ ...(beyond && cur.labelKept ? { labelKept: false } : {}),
55
+ },
56
+ };
57
+ }
58
+
59
+ // Typed fields AND a captured Street View frame survive a re-pin; the
60
+ // coordinates always win. A DERIVED label dies with the old spot.
61
+ return {
62
+ kind: 'rebuild',
63
+ address: {
64
+ ...pickedAddressState(cur, { lat: next.lat, lng: next.lng, houseNumber: null }),
65
+ accuracy,
66
+ ...(cur?.streetView ? { streetView: cur.streetView } : {}),
67
+ },
68
+ };
69
+ }
70
+
71
+ /**
72
+ * The address after landing on the device's current fix — the bootstrap
73
+ * override, used while no address exists yet.
74
+ *
75
+ * It sets NO `pickedAt`, so the resulting label counts as DERIVED and
76
+ * re-derives freely on the next move. A fix is where the phone is, not a
77
+ * property the applicant named.
78
+ */
79
+ export function fixApplied(
80
+ cur: AddressState | null,
81
+ fix: { lat: number; lng: number; accuracy: number | null; label: string | null; parts?: unknown },
82
+ ): AddressState {
83
+ return {
84
+ ...pickedAddressState(cur, { lat: fix.lat, lng: fix.lng, houseNumber: null }),
85
+ accuracy: fix.accuracy,
86
+ ...(cur?.streetView ? { streetView: cur.streetView } : {}),
87
+ ...(fix.label ? { label: fix.label } : {}),
88
+ ...(fix.parts ? { parts: fix.parts as AddressState['parts'] } : {}),
89
+ };
90
+ }
91
+
92
+ /** The address with the human-confirmed label dropped, ready to be relabelled
93
+ * from the pin. */
94
+ export function withoutPickedLabel(cur: AddressState): AddressState {
95
+ const { label: _l, parts: _p, pickedAt: _a, labelKept: _k, ...rest } = cur;
96
+ return rest;
97
+ }
@@ -0,0 +1,63 @@
1
+ import { isAddressStep } from './address-flow';
2
+ import type { KYCStep } from '../types/config';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Recovering an attempt resumed onto an address step the flow no longer has.
6
+ //
7
+ // The address flow is the only part of the SDK whose SHAPE depends on something
8
+ // other than the workflow the applicant is walking: `searchAvailable` comes from
9
+ // a server flag that arrives after the session restore has already written the
10
+ // step it was saved on. Add a republish that drops the entrance photo, and a
11
+ // resumed applicant can be sitting on a screen the current order does not
12
+ // contain — where Continue has nothing to advance to and Back has no
13
+ // predecessor, so the flow is simply stuck.
14
+ //
15
+ // The rule is FORWARD: a step the flow no longer has is a capture it is no
16
+ // longer asking for, so the applicant carries on rather than being sent back to
17
+ // redo work they had already finished.
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /** The address flow's four screens, in the order they are always walked. */
21
+ const CANONICAL: readonly KYCStep[] = [
22
+ 'address-search',
23
+ 'address-collection',
24
+ 'address-entrance',
25
+ 'address-review',
26
+ ];
27
+
28
+ /**
29
+ * The step that follows the address region in an order that CONTAINS it — where
30
+ * an applicant belongs when the workflow has dropped address collection
31
+ * altogether. Computed from the real order rather than guessed, so it is the
32
+ * step they would have reached had they finished.
33
+ */
34
+ export function addressExitStep(orderWithAddress: readonly KYCStep[]): KYCStep | null {
35
+ for (let i = orderWithAddress.length - 1; i >= 0; i -= 1) {
36
+ if (isAddressStep(orderWithAddress[i]!)) return orderWithAddress[i + 1] ?? null;
37
+ }
38
+ return null;
39
+ }
40
+
41
+ /**
42
+ * Where to send an applicant sitting on an address step this flow does not
43
+ * contain, or null when there is nothing wrong.
44
+ *
45
+ * `inFlow` is the address steps the CURRENT order has, in order; `exit` is where
46
+ * to go when it has none left (see `addressExitStep`).
47
+ */
48
+ export function recoverAddressStep(
49
+ current: KYCStep,
50
+ inFlow: readonly KYCStep[],
51
+ exit: KYCStep | null,
52
+ ): KYCStep | null {
53
+ if (!isAddressStep(current) || inFlow.includes(current)) return null;
54
+ const from = CANONICAL.indexOf(current);
55
+ for (let i = from + 1; i < CANONICAL.length; i += 1) {
56
+ const step = CANONICAL[i]!;
57
+ if (inFlow.includes(step)) return step;
58
+ }
59
+ // Nothing after it either: the last screen the flow does have (KYB resumed
60
+ // onto a review it never renders belongs on its premises pin), else out of
61
+ // the address flow entirely.
62
+ return inFlow[inFlow.length - 1] ?? exit;
63
+ }
@@ -0,0 +1,78 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ // ─── Server-side pictures, fetched with the SDK's own bearer ────────────────
4
+ //
5
+ // The review card's two pictures (the static map, the framed Street View)
6
+ // come through OUR server, so the request has to carry the SDK's bearer.
7
+ // React Native's <Image> accepts `source.headers` for exactly this and honours
8
+ // it on iOS; on Android (RN 0.85, new architecture) the header never leaves
9
+ // the device: the server answered 401 to every thumbnail request and the card
10
+ // drew an empty bordered box where the entrance should have been (Galaxy S24,
11
+ // 2026-09-07). So the bytes are fetched with the same `fetch` the API client
12
+ // uses, which carries headers on both platforms, and <Image> is handed a data
13
+ // URI it cannot get wrong. The bytes are base64-encoded here rather than via
14
+ // Blob + FileReader, which RN's fetch cannot supply (see bytesToBase64).
15
+
16
+ export interface AuthedImageSource {
17
+ uri: string;
18
+ headers: Record<string, string>;
19
+ }
20
+
21
+ const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
22
+
23
+ /** Plain base64, in JS. React Native's `Response.blob()` cannot build a Blob
24
+ * from the ArrayBuffer its own fetch holds ("Creating blobs from
25
+ * 'ArrayBuffer' … are not supported"), so Blob and FileReader are out; a
26
+ * thumbnail is tens of kilobytes and this loop is nothing. */
27
+ export function bytesToBase64(bytes: Uint8Array): string {
28
+ let out = '';
29
+ for (let i = 0; i < bytes.length; i += 3) {
30
+ const a = bytes[i] ?? 0;
31
+ const b = bytes[i + 1] ?? 0;
32
+ const c = bytes[i + 2] ?? 0;
33
+ const triple = (a << 16) | (b << 8) | c;
34
+ out += B64.charAt((triple >> 18) & 63) + B64.charAt((triple >> 12) & 63);
35
+ out += i + 1 < bytes.length ? B64.charAt((triple >> 6) & 63) : '=';
36
+ out += i + 2 < bytes.length ? B64.charAt(triple & 63) : '=';
37
+ }
38
+ return out;
39
+ }
40
+
41
+ export async function fetchImageDataUri(source: AuthedImageSource, signal?: AbortSignal): Promise<string> {
42
+ const res = await fetch(source.uri, { headers: source.headers, signal });
43
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
44
+ const mime = res.headers.get('content-type')?.split(';')[0]?.trim() || 'image/jpeg';
45
+ const bytes = new Uint8Array(await res.arrayBuffer());
46
+ return `data:${mime};base64,${bytesToBase64(bytes)}`;
47
+ }
48
+
49
+ export interface AuthedImage {
50
+ /** The picture as a data URI once it has arrived. */
51
+ uri: string | null;
52
+ /** It never will: the request was refused or the bytes were unreadable. */
53
+ failed: boolean;
54
+ }
55
+
56
+ const IDLE: AuthedImage = { uri: null, failed: false };
57
+
58
+ /** One server picture, keyed on its URL; `label` names it in the dev log. */
59
+ export function useAuthedImage(source: AuthedImageSource | null, label: string): AuthedImage {
60
+ const [state, setState] = useState<AuthedImage>(IDLE);
61
+ const key = source?.uri ?? null;
62
+ useEffect(() => {
63
+ setState(IDLE);
64
+ if (!source) return undefined;
65
+ const controller = new AbortController();
66
+ fetchImageDataUri(source, controller.signal)
67
+ .then((uri) => setState({ uri, failed: false }))
68
+ .catch((err: unknown) => {
69
+ if (controller.signal.aborted) return;
70
+ if (__DEV__) console.warn(`${label} failed to load`, err);
71
+ setState({ uri: null, failed: true });
72
+ });
73
+ return () => controller.abort();
74
+ // The headers are the SDK's own and never change; the URL is the picture.
75
+ // eslint-disable-next-line react-hooks/exhaustive-deps
76
+ }, [key]);
77
+ return state;
78
+ }