@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,143 @@
1
+ // Web Mercator math for the dependency-free map pin picker. Pure and
2
+ // unit-tested — the picker is a thin gesture shell over these.
3
+ //
4
+ // Deliberately NOT react-native-maps: that means native modules, per-platform
5
+ // setup and a Google Maps API key per org for what the picker needs — pan,
6
+ // zoom, one pin. The standard slippy-map maths below is ~60 lines, fully
7
+ // testable, and the tiles are plain <Image>s.
8
+ //
9
+ // A MIRROR of the web SDK's lib/map-tiles.ts — keep the two in lockstep.
10
+
11
+ export const TILE_SIZE = 256;
12
+ export const MIN_ZOOM = 3;
13
+ export const MAX_ZOOM = 19;
14
+
15
+ // Web Mercator's poles — beyond this the projection diverges.
16
+ const MAX_LAT = 85.05112878;
17
+
18
+ export interface LatLng {
19
+ lat: number;
20
+ lng: number;
21
+ }
22
+
23
+ export function clampLat(lat: number): number {
24
+ return Math.max(-MAX_LAT, Math.min(MAX_LAT, lat));
25
+ }
26
+
27
+ function wrapLng(lng: number): number {
28
+ let l = lng;
29
+ while (l > 180) l -= 360;
30
+ while (l < -180) l += 360;
31
+ return l;
32
+ }
33
+
34
+ /** World size in pixels at a zoom level. */
35
+ export function worldSize(zoom: number): number {
36
+ return TILE_SIZE * 2 ** zoom;
37
+ }
38
+
39
+ /** Project a coordinate to world pixels at a zoom level. */
40
+ export function latLngToWorld(point: LatLng, zoom: number): { x: number; y: number } {
41
+ const size = worldSize(zoom);
42
+ const lat = clampLat(point.lat);
43
+ const sin = Math.sin((lat * Math.PI) / 180);
44
+ // Clamped into the world: at the pole cap the log term lands a float
45
+ // epsilon outside [0, size], which would render as a phantom tile row.
46
+ const y = (0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI)) * size;
47
+ return {
48
+ x: ((wrapLng(point.lng) + 180) / 360) * size,
49
+ y: Math.min(size, Math.max(0, y)),
50
+ };
51
+ }
52
+
53
+ /** Unproject world pixels back to a coordinate. */
54
+ export function worldToLatLng(x: number, y: number, zoom: number): LatLng {
55
+ const size = worldSize(zoom);
56
+ const n = Math.PI - (2 * Math.PI * y) / size;
57
+ return {
58
+ lat: (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n))),
59
+ lng: wrapLng((x / size) * 360 - 180),
60
+ };
61
+ }
62
+
63
+ export interface TilePlacement {
64
+ key: string;
65
+ url: string;
66
+ /** CSS position relative to the viewport's top-left corner. */
67
+ left: number;
68
+ top: number;
69
+ }
70
+
71
+ /**
72
+ * The OSM tiles covering a viewport centred on `center`. Tiles outside the
73
+ * world (above/below the poles) are skipped; longitude wraps.
74
+ */
75
+ export function visibleTiles(
76
+ center: LatLng,
77
+ zoom: number,
78
+ width: number,
79
+ height: number,
80
+ ): TilePlacement[] {
81
+ const world = latLngToWorld(center, zoom);
82
+ const tiles: TilePlacement[] = [];
83
+ const tileCount = 2 ** zoom;
84
+ const originX = world.x - width / 2;
85
+ const originY = world.y - height / 2;
86
+ const first = { x: Math.floor(originX / TILE_SIZE), y: Math.floor(originY / TILE_SIZE) };
87
+ const last = {
88
+ x: Math.floor((originX + width) / TILE_SIZE),
89
+ y: Math.floor((originY + height) / TILE_SIZE),
90
+ };
91
+ for (let ty = first.y; ty <= last.y; ty += 1) {
92
+ if (ty < 0 || ty >= tileCount) continue;
93
+ for (let tx = first.x; tx <= last.x; tx += 1) {
94
+ const wrappedX = ((tx % tileCount) + tileCount) % tileCount;
95
+ tiles.push({
96
+ key: `${zoom}/${tx}/${ty}`,
97
+ url: `https://tile.openstreetmap.org/${zoom}/${wrappedX}/${ty}.png`,
98
+ left: tx * TILE_SIZE - originX,
99
+ top: ty * TILE_SIZE - originY,
100
+ });
101
+ }
102
+ }
103
+ return tiles;
104
+ }
105
+
106
+ /** The centre after a drag of (dx, dy) viewport pixels. */
107
+ export function panCenter(center: LatLng, zoom: number, dx: number, dy: number): LatLng {
108
+ const world = latLngToWorld(center, zoom);
109
+ const next = worldToLatLng(world.x - dx, world.y - dy, zoom);
110
+ return { lat: clampLat(next.lat), lng: next.lng };
111
+ }
112
+
113
+ /** Sensible starting views: gov-DB countries at country zoom, else a world view. */
114
+ const COUNTRY_CENTERS: Record<string, LatLng> = {
115
+ NG: { lat: 9.06, lng: 8.68 },
116
+ GH: { lat: 7.95, lng: -1.02 },
117
+ KE: { lat: 0.02, lng: 37.9 },
118
+ ZA: { lat: -28.48, lng: 24.68 },
119
+ CI: { lat: 7.54, lng: -5.55 },
120
+ };
121
+
122
+ export function defaultMapView(country: string | undefined): { center: LatLng; zoom: number } {
123
+ const center = country ? COUNTRY_CENTERS[country.toUpperCase()] : undefined;
124
+ return center ? { center, zoom: 6 } : { center: { lat: 6.5, lng: 12 }, zoom: 3 };
125
+ }
126
+
127
+ /**
128
+ * The height of the map surface on the pin step, from the viewport.
129
+ *
130
+ * A phone gets 40% of its height, clamped to 240–360: on a phone the map
131
+ * owns every touch, so the taller it is the less page is left to scroll on,
132
+ * and Continue has to stay reachable. From a tablet-class width (the web
133
+ * SDK's `sm` breakpoint) it is a flat 420. Mirrors the web SDK's
134
+ * `h-[40vh] min-h-[240px] max-h-[360px] sm:h-[420px]` on AddressCollectionStep
135
+ * and the Flutter `mapSurfaceHeight`; keep the three in lockstep.
136
+ */
137
+ export const MAP_SURFACE = { phoneFraction: 0.4, phoneMin: 240, phoneMax: 360, wideBreakpoint: 640, wide: 420 } as const;
138
+
139
+ export function mapSurfaceHeight(viewport: { width: number; height: number }): number {
140
+ if (viewport.width >= MAP_SURFACE.wideBreakpoint) return MAP_SURFACE.wide;
141
+ const fraction = Math.round(viewport.height * MAP_SURFACE.phoneFraction);
142
+ return Math.min(MAP_SURFACE.phoneMax, Math.max(MAP_SURFACE.phoneMin, fraction));
143
+ }
@@ -0,0 +1,27 @@
1
+ // ---------------------------------------------------------------------------
2
+ // The Proof of Address step's country gate.
3
+ //
4
+ // On the ADDRESS scope the declared country is the applicant's own claim about
5
+ // their market: it picks the document kinds on offer, the PoA vendor market and
6
+ // rides the submission as the verification's country, and the scope seeds none
7
+ // (see AddressCountryControl). A document uploaded with no country behind it is
8
+ // not yet a complete answer, so Continue holds until one is declared (user
9
+ // decision 2026-09-08). Elsewhere the flow's own country stands and the gate
10
+ // never bites; an org that accepts exactly ONE country has the control show it
11
+ // as a settled fact, which counts as declared.
12
+ //
13
+ // A THREE-WAY MIRROR of the web SDK's lib/poa-country-gate.ts and Flutter's
14
+ // config/poa_country_gate.dart; change the rule in one and change all three.
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /** Whether the Proof of Address step may continue as far as the COUNTRY is
18
+ * concerned (the upload has its own gate). */
19
+ export function poaCountryDeclared(facts: {
20
+ scope: string | null;
21
+ selectedCountry: string | null | undefined;
22
+ offered: readonly string[];
23
+ }): boolean {
24
+ if (facts.scope !== 'address') return true;
25
+ if (facts.selectedCountry?.trim()) return true;
26
+ return facts.offered.length === 1;
27
+ }
@@ -0,0 +1,116 @@
1
+ import type { VerificationOutcome } from './result-wait';
2
+ import type { BiometricCopyText } from '../config/biometricOptions';
3
+
4
+ // ─── What the terminal screens say ──────────────────────────────────────────
5
+ //
6
+ // Pure so it is testable without React. The server's own reason wins on a
7
+ // decline or an error when it sent one: it is written for the applicant.
8
+ // UK English, no em dashes (user-facing copy rule).
9
+
10
+ export type ResultTone = 'success' | 'error' | 'info';
11
+
12
+ export interface ResultCopy {
13
+ tone: ResultTone;
14
+ title: string;
15
+ description: string;
16
+ }
17
+
18
+ export interface WaitingCopy {
19
+ title: string;
20
+ description: string;
21
+ }
22
+
23
+ /**
24
+ * The ONE loading screen after the capture. On a re-authentication that waits
25
+ * for its verdict it spans the selfie upload, the submission and the poll, so
26
+ * it names the check rather than any of the three steps behind it. A retry in
27
+ * flight replaces the description, never the title: the person is still
28
+ * waiting for the same thing. `override` is the org's own words for the
29
+ * screen (lib/biometric-copy.ts), field by field over the default.
30
+ */
31
+ export function describeWaiting(opts: {
32
+ scope: string | null;
33
+ waitsForResult: boolean;
34
+ retry?: { attempt: number; total: number } | null;
35
+ override?: BiometricCopyText | null;
36
+ }): WaitingCopy {
37
+ const base = withOverride(waitingCopyFor(opts.scope, opts.waitsForResult), opts.override);
38
+ if (opts.retry) {
39
+ return { title: base.title, description: `Connection issue, retrying (${opts.retry.attempt}/${opts.retry.total}).` };
40
+ }
41
+ return base;
42
+ }
43
+
44
+ function waitingCopyFor(scope: string | null, waitsForResult: boolean): WaitingCopy {
45
+ if (scope === 'biometric-authentication') {
46
+ return waitsForResult
47
+ ? { title: "Checking it's you", description: 'Matching your selfie against the photo on record. This usually takes a few seconds.' }
48
+ : { title: 'Sending your face check', description: 'This only takes a moment.' };
49
+ }
50
+ if (scope === 'biometric-enrollment') {
51
+ return { title: 'Saving your selfie', description: 'It becomes the reference for your future face checks.' };
52
+ }
53
+ return { title: 'Submitting your verification', description: 'Please wait a moment.' };
54
+ }
55
+
56
+ /** The org's own words for a screen, over the default, field by field. */
57
+ function withOverride<T extends { title: string; description: string }>(base: T, override?: BiometricCopyText | null): T {
58
+ if (!override) return base;
59
+ return {
60
+ ...base,
61
+ ...(override.title ? { title: override.title } : {}),
62
+ ...(override.description ? { description: override.description } : {}),
63
+ };
64
+ }
65
+
66
+ /** What the person is told, per outcome. The server's own reason wins on a
67
+ * decline or an error when it sent one; it is written for the applicant.
68
+ * `copy` is the org's own words for the two verdict screens: on a decline
69
+ * its description wins even over the server's reason, since the org chose
70
+ * to say that. */
71
+ export function describeOutcome(
72
+ outcome: VerificationOutcome,
73
+ copy?: { verified?: BiometricCopyText | null; declined?: BiometricCopyText | null },
74
+ ): ResultCopy {
75
+ if (outcome.kind === 'timeout') {
76
+ return {
77
+ tone: 'info',
78
+ title: 'Still checking',
79
+ description: "This is taking longer than usual. You'll be notified as soon as it's done.",
80
+ };
81
+ }
82
+ switch (outcome.status) {
83
+ case 'approved':
84
+ return withOverride(
85
+ { tone: 'success', title: "You're verified", description: 'Your face matched the photo on record.' },
86
+ copy?.verified,
87
+ );
88
+ case 'declined':
89
+ return withOverride(
90
+ {
91
+ tone: 'error',
92
+ title: "We couldn't confirm it's you",
93
+ description: outcome.reason ?? "Your face didn't match the photo on record.",
94
+ },
95
+ copy?.declined,
96
+ );
97
+ case 'in_review':
98
+ return {
99
+ tone: 'info',
100
+ title: 'Under review',
101
+ description: "A reviewer will take a look. You'll be notified of the outcome.",
102
+ };
103
+ case 'error':
104
+ return {
105
+ tone: 'error',
106
+ title: 'Something went wrong',
107
+ description: outcome.reason ?? "We couldn't complete your check. Please try again in a moment.",
108
+ };
109
+ default:
110
+ return {
111
+ tone: 'info',
112
+ title: 'Check submitted',
113
+ description: "You'll be notified of the result.",
114
+ };
115
+ }
116
+ }
@@ -0,0 +1,53 @@
1
+ import type { SessionStatus, VerificationStatusResponse } from '../services/api-types';
2
+
3
+ // ─── Waiting for a verdict in the flow ──────────────────────────────────────
4
+ //
5
+ // The platform is fire-and-forget: /verify answers in milliseconds and the
6
+ // worker settles the check afterwards. A re-authentication is the one flow
7
+ // whose verdict the person is waiting for RIGHT THERE, so the submitted step
8
+ // polls the publishable status endpoint (state + reason, never result data)
9
+ // until the check leaves its pending states. Pure and injectable, like the
10
+ // presence watch wait: the screen owns nothing but the rendering.
11
+
12
+ export const RESULT_WAIT_MS = 60 * 1000;
13
+ export const RESULT_POLL_MS = 1500;
14
+
15
+ /** The states a submitted check passes through before it settles. */
16
+ const PENDING: ReadonlySet<SessionStatus> = new Set(['not_started', 'in_progress', 'processing']);
17
+
18
+ export type VerificationOutcome =
19
+ | { kind: 'settled'; status: SessionStatus; reason: string | null; reasonCode: string | null }
20
+ | { kind: 'timeout' };
21
+
22
+ export interface AwaitOutcomeDeps {
23
+ /** One status read; null on any failure (the wait keeps going). */
24
+ fetchStatus: () => Promise<VerificationStatusResponse | null>;
25
+ sleep?: (ms: number) => Promise<void>;
26
+ now?: () => number;
27
+ waitMs?: number;
28
+ pollMs?: number;
29
+ }
30
+
31
+ export function isPendingStatus(status: SessionStatus): boolean {
32
+ return PENDING.has(status);
33
+ }
34
+
35
+ /**
36
+ * Poll until the check settles or the budget runs out. A failed read is not
37
+ * a verdict: it is skipped and the next poll tries again, so a network blip
38
+ * mid-wait never reads as an outcome.
39
+ */
40
+ export async function awaitVerificationOutcome(deps: AwaitOutcomeDeps): Promise<VerificationOutcome> {
41
+ const sleep = deps.sleep ?? ((ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
42
+ const now = deps.now ?? Date.now;
43
+ const deadline = now() + (deps.waitMs ?? RESULT_WAIT_MS);
44
+ const pollMs = deps.pollMs ?? RESULT_POLL_MS;
45
+ for (;;) {
46
+ const read = await deps.fetchStatus();
47
+ if (read && !isPendingStatus(read.status)) {
48
+ return { kind: 'settled', status: read.status, reason: read.reason ?? null, reasonCode: read.reasonCode ?? null };
49
+ }
50
+ if (now() >= deadline) return { kind: 'timeout' };
51
+ await sleep(pollMs);
52
+ }
53
+ }
@@ -0,0 +1,26 @@
1
+ // The review card's map falls back in the SAME order on every SDK: the picture
2
+ // (the Maps Static API through the server), then the framed Google map the pin
3
+ // step drew, then the built-in tiles. Pure, so a test can pin the order — the
4
+ // Flutter card skipped the middle rung and confirmed the address on
5
+ // OpenStreetMap where RN confirmed it on Google (user report 2026-09-08).
6
+ // Mirrors kyc-sdk-flutter's reviewMapSurface; keep the two in lockstep.
7
+
8
+ export type ReviewMapSurface = 'stub' | 'picture' | 'pending' | 'framed' | 'builtIn';
9
+
10
+ export function reviewMapSurface(facts: {
11
+ vendorsStubbed: boolean;
12
+ /** The picture's bytes have landed. */
13
+ hasStaticMap: boolean;
14
+ /** The picture was refused (a project without the Static API 404s). */
15
+ staticMapFailed: boolean;
16
+ /** A server-minted maps frame URL this install can render. */
17
+ hasFrame: boolean;
18
+ }): ReviewMapSurface {
19
+ if (facts.vendorsStubbed) return 'stub';
20
+ if (facts.hasStaticMap) return 'picture';
21
+ // Nothing to draw yet and no verdict either: hold the picture's space rather
22
+ // than flashing the live map for the half second before the bytes land.
23
+ if (!facts.staticMapFailed) return 'pending';
24
+ if (facts.hasFrame) return 'framed';
25
+ return 'builtIn';
26
+ }
@@ -0,0 +1,31 @@
1
+ // Workflow SCOPE — what a flow verifies about the subject (mirror of the
2
+ // server's lib/workflows/scope.ts and the web SDK's lib/scope.ts; keep the
3
+ // three in lockstep). Absent = the full verification.
4
+
5
+ export type WorkflowScope =
6
+ | 'address'
7
+ | 'biometric-authentication'
8
+ | 'biometric-enrollment'
9
+ | 'questionnaire'
10
+ | 'contact';
11
+
12
+ /** The marker idType a scoped submission carries (the KYB product-in-idType
13
+ * convention — the server requires the matching published workflow). */
14
+ export const SCOPE_ID_TYPES: Record<WorkflowScope, string> = {
15
+ address: 'address',
16
+ 'biometric-authentication': 'biometric-auth',
17
+ 'biometric-enrollment': 'biometric-enroll',
18
+ questionnaire: 'questionnaire',
19
+ contact: 'contact',
20
+ };
21
+
22
+ export function configScope(config: { scope?: WorkflowScope | string }): WorkflowScope | null {
23
+ const value = config.scope;
24
+ return value && value in SCOPE_ID_TYPES ? (value as WorkflowScope) : null;
25
+ }
26
+
27
+ /** The biometric scopes run the liveness capture; every other scope has no
28
+ * camera step at all. */
29
+ export function isFaceScope(scope: WorkflowScope | null): boolean {
30
+ return scope === 'biometric-authentication' || scope === 'biometric-enrollment';
31
+ }
@@ -0,0 +1,79 @@
1
+ // ─── Waiting for the selfie upload from a later step ────────────────────────
2
+ //
3
+ // With the selfie review hidden (the biometric scopes' default), the liveness
4
+ // step hands over the moment the capture ring has closed rather than when the
5
+ // upload lands, so the person sees ONE loading screen from the shutter to the
6
+ // verdict instead of three. The upload keeps running in the background and
7
+ // reports to the store (`selfieUpload`, written by liveness/useSelfieUpload);
8
+ // the submitted step waits on that record before it submits. Pure and
9
+ // injectable, like the result wait: the screen owns nothing but the rendering.
10
+
11
+ export type SelfieUploadStatus = 'idle' | 'uploading' | 'done' | 'failed';
12
+
13
+ export interface SelfieUploadState {
14
+ status: SelfieUploadStatus;
15
+ /** The failure message, set only on `failed`. */
16
+ message: string | null;
17
+ }
18
+
19
+ export const IDLE_SELFIE_UPLOAD: SelfieUploadState = { status: 'idle', message: null };
20
+
21
+ export type SelfieUploadWait = { ok: true } | { ok: false; message: string };
22
+
23
+ export interface SelfieUploadSnapshot {
24
+ selfieUpload: SelfieUploadState;
25
+ /** `mediaIds.selfie`: the durable proof the selfie is on the server. */
26
+ selfieMediaId: string | undefined;
27
+ }
28
+
29
+ export const SELFIE_UPLOAD_WAIT_MS = 90 * 1000;
30
+
31
+ const TIMED_OUT = 'Your selfie could not be sent. Check your connection and try again.';
32
+
33
+ /**
34
+ * Whether the upload has settled, and how. A restored session carries the
35
+ * media id with the status still `idle` (nothing uploaded this visit), which
36
+ * counts as settled; an `idle` record with no media id is an upload that has
37
+ * not started yet, so the caller keeps waiting.
38
+ */
39
+ export function selfieUploadSettled(snapshot: SelfieUploadSnapshot): SelfieUploadWait | null {
40
+ const { status, message } = snapshot.selfieUpload;
41
+ if (status === 'failed') return { ok: false, message: message ?? TIMED_OUT };
42
+ if (status === 'done' || (status === 'idle' && !!snapshot.selfieMediaId)) return { ok: true };
43
+ return null;
44
+ }
45
+
46
+ export interface AwaitSelfieUploadDeps {
47
+ read: () => SelfieUploadSnapshot;
48
+ /** Fires the listener on every store change; returns the unsubscribe. */
49
+ subscribe: (listener: () => void) => () => void;
50
+ timeoutMs?: number;
51
+ setTimer?: (fn: () => void, ms: number) => unknown;
52
+ clearTimer?: (handle: unknown) => void;
53
+ }
54
+
55
+ /** Resolve once the upload has settled, or with a failure at the deadline. */
56
+ export function awaitSelfieUpload(deps: AwaitSelfieUploadDeps): Promise<SelfieUploadWait> {
57
+ const settled = selfieUploadSettled(deps.read());
58
+ if (settled) return Promise.resolve(settled);
59
+ const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
60
+ const clearTimer = deps.clearTimer ?? ((h) => clearTimeout(h as ReturnType<typeof setTimeout>));
61
+ return new Promise((resolve) => {
62
+ let done = false;
63
+ const finish = (result: SelfieUploadWait) => {
64
+ if (done) return;
65
+ done = true;
66
+ unsubscribe();
67
+ clearTimer(timer);
68
+ resolve(result);
69
+ };
70
+ const unsubscribe = deps.subscribe(() => {
71
+ const next = selfieUploadSettled(deps.read());
72
+ if (next) finish(next);
73
+ });
74
+ const timer = setTimer(() => finish({ ok: false, message: TIMED_OUT }), deps.timeoutMs ?? SELFIE_UPLOAD_WAIT_MS);
75
+ // The store may have moved between the first read and the subscription.
76
+ const again = selfieUploadSettled(deps.read());
77
+ if (again) finish(again);
78
+ });
79
+ }
@@ -0,0 +1,42 @@
1
+ // The Street View entrance frame's maths. A mirror of the web SDK's
2
+ // StreetViewFramer `frameFov` and the Flutter street_view_fov.dart; keep the
3
+ // three in lockstep.
4
+
5
+ export const clamp = (v: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, v));
6
+
7
+ /** The frame the applicant captured: coordinates only, so the server fetches
8
+ * the image with its own key. */
9
+ export interface StreetViewFrame {
10
+ panoId: string;
11
+ heading: number;
12
+ pitch: number;
13
+ fov: number;
14
+ }
15
+
16
+ /**
17
+ * The field of view a centred sub-frame of the viewport actually subtends.
18
+ * The frame is entrance-sized guidance, so the STORED image must be what the
19
+ * frame showed, not the whole panorama — otherwise "fit your gate in the
20
+ * frame" captures a streetscape with the gate somewhere in it. Exact
21
+ * projection maths (a perspective view is a flat plane, so a width fraction
22
+ * maps through tan, not linearly).
23
+ */
24
+ export function frameFov(viewportFovDeg: number, widthFraction: number): number {
25
+ const fraction = clamp(widthFraction, 0.1, 1);
26
+ const half = (viewportFovDeg * Math.PI) / 360;
27
+ return (2 * Math.atan(fraction * Math.tan(half)) * 180) / Math.PI;
28
+ }
29
+
30
+ /** The frame to store for a reported view: the slice the frame subtends when
31
+ * both widths are known, else the whole view; fov 10..120, pitch ±90. */
32
+ export function captureStreetViewFrame(
33
+ pov: { panoId: string; heading: number; pitch: number; viewFov: number },
34
+ frameWidth: number,
35
+ viewWidth: number,
36
+ ): StreetViewFrame {
37
+ const fov =
38
+ frameWidth > 0 && viewWidth > 0
39
+ ? clamp(frameFov(pov.viewFov, frameWidth / viewWidth), 10, 120)
40
+ : clamp(pov.viewFov, 10, 120);
41
+ return { panoId: pov.panoId, heading: pov.heading, pitch: clamp(pov.pitch, -90, 90), fov };
42
+ }
@@ -0,0 +1,47 @@
1
+ import type React from 'react';
2
+
3
+ import { tryRequire } from '../services/fingerprint-sources';
4
+
5
+ // `react-native-webview` is an OPTIONAL peer. Absent, every surface that
6
+ // would ride it (the framed Google map, the framed Street View) degrades to
7
+ // its dependency-free path (the OSM picker, the entrance photo), and the
8
+ // FLOW MODEL must know that too, or it would offer a step the phone cannot
9
+ // render. Resolved once and cached: a require that failed will fail again.
10
+
11
+ export interface WebViewLike {
12
+ injectJavaScript(script: string): void;
13
+ }
14
+
15
+ export type WebViewComponent = React.ComponentType<{
16
+ ref?: React.Ref<WebViewLike>;
17
+ source: { uri: string };
18
+ style?: object;
19
+ javaScriptEnabled?: boolean;
20
+ /** Android: ask a parent ScrollView not to intercept the WebView's drags. */
21
+ nestedScrollEnabled?: boolean;
22
+ onMessage?: (event: { nativeEvent: { data: string } }) => void;
23
+ onError?: () => void;
24
+ }>;
25
+
26
+ let resolved: WebViewComponent | null | undefined;
27
+
28
+ export function loadWebView(): WebViewComponent | null {
29
+ if (resolved === undefined) {
30
+ const mod = tryRequire<{ WebView?: WebViewComponent; default?: WebViewComponent }>(() =>
31
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
32
+ require('react-native-webview'),
33
+ );
34
+ resolved = mod?.WebView ?? mod?.default ?? null;
35
+ }
36
+ return resolved;
37
+ }
38
+
39
+ /** Whether a WebView can be rendered on this install. */
40
+ export function webViewAvailable(): boolean {
41
+ return loadWebView() != null;
42
+ }
43
+
44
+ /** Test seam: forget the cached answer. */
45
+ export function __resetWebViewAvailability(): void {
46
+ resolved = undefined;
47
+ }
@@ -562,7 +562,12 @@ export function useLiveness(opts: UseLivenessOptions = {}): UseLivenessReturn {
562
562
  setState((s) => ({
563
563
  ...s,
564
564
  phase: 'complete',
565
- instruction: 'Capture complete',
565
+ // No line at all. The ring closing green on the same frame as the
566
+ // shutter IS the completion signal, and the review screen follows within
567
+ // the beat — narrating the upload over it said nothing the user needed
568
+ // and read as a delay. The instruction also drives the voice guidance,
569
+ // so an empty one is silent as well as blank.
570
+ instruction: '',
566
571
  activeChallenge: null,
567
572
  positionGuidance: null,
568
573
  }));