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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (246) hide show
  1. package/README.md +151 -5
  2. package/android/build.gradle +35 -3
  3. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +77 -0
  4. package/app.plugin.js +89 -8
  5. package/ios/HybridMyazaFaceDetector.swift +16 -3
  6. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.cpp +23 -0
  7. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.hpp +2 -0
  8. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaFaceDetectorSpec.kt +9 -0
  9. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.cpp +8 -0
  10. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.hpp +43 -0
  11. package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.hpp +17 -0
  12. package/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
  13. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec.swift +2 -0
  14. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec_cxx.swift +31 -0
  15. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.cpp +2 -0
  16. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.hpp +3 -0
  17. package/package.json +14 -2
  18. package/src/MyazaBiometricAuth.tsx +119 -0
  19. package/src/MyazaKYC.tsx +12 -0
  20. package/src/components/CountryField.tsx +8 -0
  21. package/src/components/CountryRegionPicker.tsx +184 -0
  22. package/src/components/DashedBorder.tsx +55 -0
  23. package/src/components/DialCodePicker.tsx +53 -67
  24. package/src/components/DialCodeRow.tsx +102 -0
  25. package/src/components/FramedMapPicker.tsx +122 -0
  26. package/src/components/GeoBadge.tsx +34 -0
  27. package/src/components/Icon.tsx +2 -176
  28. package/src/components/KycFlow.tsx +60 -32
  29. package/src/components/LineSkeleton.tsx +100 -0
  30. package/src/components/MapChrome.tsx +67 -0
  31. package/src/components/MapPinMarker.tsx +54 -0
  32. package/src/components/MapPinPicker.tsx +179 -0
  33. package/src/components/MilestoneTrack.tsx +155 -0
  34. package/src/components/MultiIdProgress.tsx +107 -0
  35. package/src/components/MyazaInput.tsx +39 -4
  36. package/src/components/MyazaSelect.tsx +2 -1
  37. package/src/components/OptionRow.tsx +22 -3
  38. package/src/components/OwnershipSlider.tsx +102 -0
  39. package/src/components/PhoneNumberInput.tsx +45 -4
  40. package/src/components/PoweredBy.tsx +5 -5
  41. package/src/components/PresenceBlocks.tsx +153 -0
  42. package/src/components/StaggerIn.tsx +63 -0
  43. package/src/components/StepView.tsx +43 -0
  44. package/src/components/StickyActions.tsx +56 -0
  45. package/src/components/dialCodeRows.ts +64 -0
  46. package/src/components/glass/FloatingSheet.tsx +73 -21
  47. package/src/components/glass/GlassSheet.tsx +14 -2
  48. package/src/components/icon-map.ts +176 -0
  49. package/src/components/stepHeaderMeta.tsx +45 -5
  50. package/src/config/addressCollection.ts +125 -0
  51. package/src/config/biometricOptions.ts +100 -0
  52. package/src/config/business.ts +26 -7
  53. package/src/config/businessDetailsValidity.ts +52 -0
  54. package/src/config/businessPrefill.ts +72 -0
  55. package/src/config/businessSteps.ts +20 -3
  56. package/src/config/consentStep.ts +19 -0
  57. package/src/config/keyPeople.ts +111 -7
  58. package/src/config/keyPeopleCorporate.ts +19 -0
  59. package/src/config/keyPeopleOwnerTypes.ts +12 -0
  60. package/src/config/keyPeopleOwners.ts +26 -0
  61. package/src/config/keyPeoplePrefill.ts +138 -0
  62. package/src/config/keyPeopleSectionDefs.ts +65 -0
  63. package/src/config/keyPeopleSections.ts +167 -0
  64. package/src/config/proofOfAddress.ts +54 -11
  65. package/src/config/regions.ts +24 -0
  66. package/src/config/registrationHint.ts +1 -1
  67. package/src/config/stepOrder.ts +100 -6
  68. package/src/config/theme.ts +8 -2
  69. package/src/config/uploadLimits.ts +35 -0
  70. package/src/config/website.ts +36 -0
  71. package/src/config/workflowMerge.ts +16 -5
  72. package/src/emrtd/activeAuth.ts +100 -0
  73. package/src/emrtd/extras.ts +76 -0
  74. package/src/emrtd/files.ts +5 -0
  75. package/src/emrtd/index.ts +1 -0
  76. package/src/emrtd/read.ts +8 -1
  77. package/src/emrtd/session.ts +41 -0
  78. package/src/emrtd/stages.ts +11 -0
  79. package/src/index.ts +54 -1
  80. package/src/lib/address-current-location.ts +171 -0
  81. package/src/lib/address-field-modes.ts +119 -0
  82. package/src/lib/address-flow.ts +172 -0
  83. package/src/lib/address-helpers.ts +48 -0
  84. package/src/lib/address-line.ts +76 -0
  85. package/src/lib/address-pin-move.ts +97 -0
  86. package/src/lib/address-step-recovery.ts +63 -0
  87. package/src/lib/authed-image.ts +78 -0
  88. package/src/lib/biometric-auth.ts +52 -0
  89. package/src/lib/biometric-copy.ts +51 -0
  90. package/src/lib/captureRing.ts +83 -0
  91. package/src/lib/contact-recovery.ts +44 -0
  92. package/src/lib/country-adoption.ts +89 -0
  93. package/src/lib/inferred-country.ts +65 -0
  94. package/src/lib/livenessLayout.ts +49 -0
  95. package/src/lib/map-frame.ts +159 -0
  96. package/src/lib/map-tiles.ts +143 -0
  97. package/src/lib/multi-id.ts +210 -0
  98. package/src/lib/poa-country-gate.ts +27 -0
  99. package/src/lib/resubmit.ts +130 -0
  100. package/src/lib/result-copy.ts +116 -0
  101. package/src/lib/result-wait.ts +53 -0
  102. package/src/lib/review-map-surface.ts +26 -0
  103. package/src/lib/scope.ts +31 -0
  104. package/src/lib/screen-corners.ts +50 -0
  105. package/src/lib/selfie-upload-wait.ts +79 -0
  106. package/src/lib/step-log.ts +24 -3
  107. package/src/lib/street-view-fov.ts +42 -0
  108. package/src/lib/use-multi-id-plan.ts +29 -0
  109. package/src/lib/webview-available.ts +47 -0
  110. package/src/liveness/useLiveness.ts +6 -1
  111. package/src/liveness/useModelReady.ts +65 -0
  112. package/src/liveness/visionCameraFaceDetector.ts +35 -0
  113. package/src/presence/background-math.ts +100 -0
  114. package/src/presence/background-store.ts +82 -0
  115. package/src/presence/background.ts +165 -0
  116. package/src/presence/foreground-service.ts +193 -0
  117. package/src/presence/fs.ts +51 -0
  118. package/src/presence/geofence.ts +41 -0
  119. package/src/presence/math.ts +44 -0
  120. package/src/presence/post.ts +40 -0
  121. package/src/presence/report.ts +87 -0
  122. package/src/presence/sampler.ts +110 -0
  123. package/src/presence/status.ts +92 -0
  124. package/src/presence/store.ts +94 -0
  125. package/src/presence/tier.ts +36 -0
  126. package/src/presence/watch-wait.ts +81 -0
  127. package/src/screens/AddressCountryControl.tsx +115 -0
  128. package/src/screens/ApplicantRoleStep.tsx +9 -5
  129. package/src/screens/BusinessCheckPanel.tsx +89 -0
  130. package/src/screens/BusinessDetailsFields.tsx +112 -0
  131. package/src/screens/BusinessDetailsStep.tsx +133 -145
  132. package/src/screens/BusinessDocumentSlot.tsx +5 -5
  133. package/src/screens/BusinessDocumentsStep.tsx +5 -56
  134. package/src/screens/BusinessKeyPeopleStep.tsx +103 -71
  135. package/src/screens/BusinessPickedCard.tsx +86 -0
  136. package/src/screens/BusinessPickedSection.tsx +51 -0
  137. package/src/screens/BusinessRegistryPickers.tsx +67 -0
  138. package/src/screens/BusinessSandboxToggle.tsx +114 -0
  139. package/src/screens/BusinessSearch.tsx +199 -0
  140. package/src/screens/BusinessSearchResults.tsx +115 -0
  141. package/src/screens/CompanyInfoFields.tsx +91 -51
  142. package/src/screens/ConsentStep.tsx +37 -17
  143. package/src/screens/ContactActions.tsx +54 -0
  144. package/src/screens/ContactDestinationField.tsx +5 -0
  145. package/src/screens/ContactEntryPanel.tsx +75 -0
  146. package/src/screens/ContactVerificationStep.tsx +43 -42
  147. package/src/screens/CountrySelectStep.tsx +13 -147
  148. package/src/screens/IdTypeStep.tsx +12 -5
  149. package/src/screens/KeepLinksSheet.tsx +88 -0
  150. package/src/screens/KeyPeopleAwaitCard.tsx +204 -0
  151. package/src/screens/KeyPeopleAwaitList.tsx +126 -0
  152. package/src/screens/KeyPeoplePending.tsx +154 -0
  153. package/src/screens/KeyPeopleSection.tsx +162 -0
  154. package/src/screens/KeyPeopleSectionsList.tsx +103 -0
  155. package/src/screens/KeyPeopleUboExemption.tsx +72 -0
  156. package/src/screens/KeyPersonCard.tsx +36 -4
  157. package/src/screens/KeyPersonForm.tsx +195 -36
  158. package/src/screens/KeyPersonKindToggle.tsx +68 -0
  159. package/src/screens/KeyPersonOwners.tsx +109 -0
  160. package/src/screens/KeyPersonRoleChips.tsx +68 -0
  161. package/src/screens/KeyPersonSheet.tsx +92 -108
  162. package/src/screens/LivenessAvatar.tsx +15 -3
  163. package/src/screens/LivenessStep.tsx +172 -52
  164. package/src/screens/NfcStep.tsx +14 -0
  165. package/src/screens/PoaDocumentTypeList.tsx +66 -0
  166. package/src/screens/ProofOfAddressParts.tsx +151 -0
  167. package/src/screens/ProofOfAddressStep.tsx +58 -96
  168. package/src/screens/SubmittedBadge.tsx +25 -0
  169. package/src/screens/SubmittedError.tsx +64 -0
  170. package/src/screens/SubmittedResult.tsx +117 -0
  171. package/src/screens/SubmittedStep.tsx +82 -119
  172. package/src/screens/SubmittedSuccess.tsx +127 -0
  173. package/src/screens/SubmittedWaiting.tsx +45 -0
  174. package/src/screens/address/AddressEntranceStep.tsx +161 -0
  175. package/src/screens/address/AddressIntroGate.tsx +148 -0
  176. package/src/screens/address/AddressMapStub.tsx +50 -0
  177. package/src/screens/address/AddressPinStep.tsx +178 -0
  178. package/src/screens/address/AddressReviewStep.tsx +162 -0
  179. package/src/screens/address/AddressSandboxTabs.tsx +158 -0
  180. package/src/screens/address/AddressSearchField.tsx +115 -0
  181. package/src/screens/address/AddressSearchStep.tsx +94 -0
  182. package/src/screens/address/CurrentLocationRow.tsx +135 -0
  183. package/src/screens/address/DetailsSheet.tsx +175 -0
  184. package/src/screens/address/DetailsSheetFields.tsx +184 -0
  185. package/src/screens/address/EntranceDropzone.tsx +178 -0
  186. package/src/screens/address/EntranceFraming.tsx +66 -0
  187. package/src/screens/address/EntrancePills.tsx +60 -0
  188. package/src/screens/address/FramedStreetView.tsx +155 -0
  189. package/src/screens/address/IntroDisclosures.tsx +210 -0
  190. package/src/screens/address/LabelDecisionRow.tsx +113 -0
  191. package/src/screens/address/PinSummaryRow.tsx +105 -0
  192. package/src/screens/address/ReviewAddressBand.tsx +111 -0
  193. package/src/screens/address/ReviewEntranceThumbs.tsx +64 -0
  194. package/src/screens/address/ReviewMapPicture.tsx +106 -0
  195. package/src/screens/address/SearchResults.tsx +105 -0
  196. package/src/screens/address/SearchScreen.tsx +176 -0
  197. package/src/screens/address/SkipForNow.tsx +43 -0
  198. package/src/screens/address/StreetViewChrome.tsx +81 -0
  199. package/src/screens/address/detail-values.ts +22 -0
  200. package/src/screens/address/fix-source.ts +27 -0
  201. package/src/screens/address/index.ts +8 -0
  202. package/src/screens/address/meta.ts +44 -0
  203. package/src/screens/address/use-address-flow.ts +200 -0
  204. package/src/screens/address/use-label-pin.ts +80 -0
  205. package/src/screens/address/use-pin-actions.ts +192 -0
  206. package/src/screens/biometric/BiometricAuthFlow.tsx +170 -0
  207. package/src/screens/consent/model.ts +79 -10
  208. package/src/screens/liveness/CaptureRing.tsx +91 -0
  209. package/src/screens/liveness/LivenessCamera.tsx +63 -0
  210. package/src/screens/liveness/LivenessHandover.tsx +43 -0
  211. package/src/screens/liveness/LivenessOutcome.tsx +4 -3
  212. package/src/screens/liveness/SelfiePreview.tsx +13 -4
  213. package/src/screens/liveness/ShutterFlash.tsx +25 -0
  214. package/src/screens/liveness/index.ts +4 -0
  215. package/src/screens/liveness/useSelfieUpload.ts +23 -3
  216. package/src/screens/nfc/NfcReadProgress.tsx +1 -0
  217. package/src/screens/nfc/NfcScanIllustration.tsx +7 -18
  218. package/src/screens/useAddressPhotoAttach.ts +77 -0
  219. package/src/screens/useAwaitingPeople.ts +90 -0
  220. package/src/screens/useBusinessDocumentAttach.ts +93 -0
  221. package/src/screens/usePoaAttach.ts +12 -15
  222. package/src/services/api-biometric.ts +37 -0
  223. package/src/services/api-types-biometric.ts +41 -0
  224. package/src/services/api-types.ts +251 -3
  225. package/src/services/api-verify-types.ts +90 -1
  226. package/src/services/api.ts +237 -3
  227. package/src/services/deviceMetadata.ts +1 -1
  228. package/src/services/errors.ts +10 -2
  229. package/src/services/location.ts +234 -0
  230. package/src/services/uploadErrors.ts +30 -0
  231. package/src/services/workflowGate.ts +8 -0
  232. package/src/specs/MyazaFaceDetector.nitro.ts +29 -0
  233. package/src/store/address.ts +97 -0
  234. package/src/store/businessCheck.ts +113 -0
  235. package/src/store/derive.ts +44 -4
  236. package/src/store/kycStore.ts +352 -16
  237. package/src/store/serverConfig.ts +20 -0
  238. package/src/store/session.ts +227 -0
  239. package/src/store/state.ts +289 -2
  240. package/src/store/submit.ts +80 -13
  241. package/src/store/submitApplicant.ts +5 -11
  242. package/src/types/business.ts +23 -2
  243. package/src/types/config.ts +70 -2
  244. package/src/types/verification.ts +13 -1
  245. package/src/types/workflow.ts +112 -1
  246. package/src/screens/KeyPeopleInviteLinks.tsx +0 -239
@@ -3,6 +3,7 @@ import { View } from 'react-native';
3
3
 
4
4
  import { radius, spacing } from '../config/theme';
5
5
  import { buildStepOrder } from '../config/stepOrder';
6
+ import { isAddressStep } from '../lib/address-flow';
6
7
  import { stepOrderOptions } from '../store/kycStore';
7
8
  import { KYCError } from '../types/verification';
8
9
  import { safeReportError } from '../services/errors';
@@ -50,6 +51,7 @@ export function KycFlow({
50
51
  const contactChallenge = useKyc((s) => s.contactChallenge);
51
52
  const serverConfig = useKyc((s) => s.serverConfig);
52
53
  const immersiveCapture = useKyc((s) => s.immersiveCapture);
54
+ const addressIntroSeen = useKyc((s) => s.addressIntroSeen);
53
55
 
54
56
  const startedRef = useRef(false);
55
57
  const reportedRef = useRef(false);
@@ -76,7 +78,26 @@ export function KycFlow({
76
78
  }
77
79
  }, [isFatal, serverConfig.statusCode, serverConfig.message, config.onError]);
78
80
 
79
- // ── Per-step header meta ──────────────────────────────────────────────────
81
+ // ── The flow's ordered steps ──────────────────────────────────────────────
82
+ // ONE list, read by the step indicator AND by the address primer below, so
83
+ // neither can describe a flow the user is not walking. This used to be a
84
+ // hand-written copy of the sequence, which meant a step added to the flow
85
+ // silently didn't count towards progress.
86
+ //
87
+ // The derivation is memoised rather than done inside the selector: zustand
88
+ // compares snapshots by identity, so a selector that mints a fresh object on
89
+ // every call re-renders forever.
90
+ const state = useKyc((s) => s);
91
+ const order = useMemo(() => buildStepOrder(stepOrderOptions(state)), [state]);
92
+
93
+ // The presence primer replaces the FIRST address step's body and carries its
94
+ // own title, so the header blanks while it is up. Matched against the order
95
+ // rather than a second copy of "which step comes first".
96
+ const addressIntroPending =
97
+ config.addressCollection?.presence?.enabled === true &&
98
+ !addressIntroSeen &&
99
+ order.find(isAddressStep) === currentStep;
100
+
80
101
  const meta = useMemo(
81
102
  () =>
82
103
  stepHeaderMeta(currentStep, {
@@ -85,40 +106,48 @@ export function KycFlow({
85
106
  selectedIdType,
86
107
  documentCapturePhase,
87
108
  contactChallenge,
109
+ addressIntroPending,
110
+ addressEntranceFraming: state.addressEntranceFraming,
111
+ poaDocumentType: state.poaDocumentType,
88
112
  }),
89
- [currentStep, config, country, selectedIdType, documentCapturePhase, contactChallenge],
113
+ [
114
+ currentStep,
115
+ config,
116
+ country,
117
+ selectedIdType,
118
+ documentCapturePhase,
119
+ contactChallenge,
120
+ addressIntroPending,
121
+ state.addressEntranceFraming,
122
+ state.poaDocumentType,
123
+ ],
90
124
  );
91
125
 
92
126
  // ── Step indicator info ───────────────────────────────────────────────────
93
- // Read from the SAME ordered list navigation uses, so the dots can never
94
- // describe a different flow than the one the user is walking. This used to be
95
- // its own hand-written copy of the sequence, which meant a step added to the
96
- // flow silently didn't count towards progress.
97
- //
98
127
  // 'submitted' is excluded on purpose — it is the terminal screen, not a step
99
128
  // to make progress towards, and the indicator is hidden there.
100
- //
101
- // The derivation is memoised rather than done inside the selector: zustand
102
- // compares snapshots by identity, so a selector that mints a fresh object on
103
- // every call re-renders forever.
104
- const state = useKyc((s) => s);
105
129
  const stepInfo = useMemo(() => {
106
- if (state.currentStep === 'submitted') return null;
107
- const steps = buildStepOrder(stepOrderOptions(state)).filter((s) => s !== 'submitted');
108
- const idx = steps.indexOf(state.currentStep);
130
+ if (currentStep === 'submitted') return null;
131
+ const steps = order.filter((s) => s !== 'submitted');
132
+ const idx = steps.indexOf(currentStep);
109
133
  if (idx < 0) return null;
110
134
  return { progress: (idx + 1) / steps.length, stepCount: steps.length };
111
- }, [state]);
135
+ }, [currentStep, order]);
112
136
 
113
137
  // The flag beside the title names the country whose IDs are on screen.
114
138
  const headerCountry =
115
139
  currentStep === 'id-type' || currentStep === 'id-input' ? country : null;
116
- const onBack = currentStep === 'consent' || currentStep === 'submitted' ? null : () => store.getState().previousStep();
140
+ // Back is hidden on the flow's OPENING step (consent, or the first real step
141
+ // when the workflow switched the consent screen off), not on consent by
142
+ // name. A multi-ID run returns to the ID picker for its next check, where
143
+ // Back means "redo the previous one", so a committed slot keeps it.
144
+ const onOpeningStep = currentStep === order[0] && state.multiIdSlots.length === 0;
145
+ const onBack = onOpeningStep || currentStep === 'submitted' ? null : () => store.getState().previousStep();
117
146
 
118
147
  // Android hardware back arrives via the <Modal>'s onRequestClose. Expose a
119
148
  // back-aware handler through `backRef` so that handler navigates a step back
120
149
  // when the flow can, instead of dismissing the whole SDK. It only reports
121
- // 'close' from the first step (consent) with close allowed; with
150
+ // 'close' from the opening step with close allowed; with
122
151
  // `disableClose` set it reports 'blocked' so back can never force the flow
123
152
  // closed. (iOS has no hardware back; its swipe-down keeps the standard
124
153
  // dismiss behaviour, handled by the Modal.)
@@ -152,22 +181,21 @@ export function KycFlow({
152
181
  country={headerCountry}
153
182
  onBack={onBack}
154
183
  onClose={onClose}
155
- // The searchable country picker pins its search box above a list that
156
- // can run to ~240 rows. Only the multi-country variant needs it: a
157
- // short flat list is happier in the normal scroll body.
184
+ // The searchable country picker (>5 countries) pins its search box
185
+ // above a ~240-row list; the address pin and entrance steps fill it
186
+ // too, since a map or panorama owns every touch and their actions
187
+ // ride StickyActions at the bottom of a BOUNDED body.
158
188
  fillsViewport={
159
- currentStep === 'country-select' &&
160
- countrySelectOptions({ config, serverConfig }).length > COUNTRY_SEARCH_THRESHOLD
189
+ (currentStep === 'country-select' &&
190
+ countrySelectOptions({ config, serverConfig }).length > COUNTRY_SEARCH_THRESHOLD) ||
191
+ currentStep === 'address-collection' ||
192
+ currentStep === 'address-entrance'
161
193
  }
162
- // A live camera asks for the whole screen: the sheet's header, padding
163
- // and scroll view are exactly what force a small viewfinder on a short
164
- // phone, and a camera you have to scroll to is a broken camera.
165
- //
166
- // Passed as a PROP rather than rendered as its own tree. The earlier
167
- // shape early-returned a different element tree, which changed the
168
- // step's parent MID-STEP — React unmounted and remounted it, wiping the
169
- // acknowledged primer and bouncing straight back to it. Scoped to the
170
- // step as well as the flag so the next step cannot inherit it.
194
+ // A live camera asks for the whole screen (a camera you have to scroll
195
+ // to is a broken camera). Passed as a PROP rather than rendered as its
196
+ // own tree: an early-returned tree changed the step's parent MID-STEP,
197
+ // so React remounted it and wiped the acknowledged primer. Scoped to
198
+ // the step as well as the flag so the next step cannot inherit it.
171
199
  immersive={immersiveCapture && currentStep === 'document-capture'}
172
200
  >
173
201
  <StepView step={currentStep} onClose={onClose} />
@@ -0,0 +1,100 @@
1
+ import React, { useEffect, useRef } from 'react';
2
+ import { Animated, View } from 'react-native';
3
+
4
+ import { radius } from '../config/theme';
5
+ import { useTheme } from './runtime';
6
+ import { MyazaText } from './Typography';
7
+ import { useReduceMotion } from './StaggerIn';
8
+
9
+ // ─── A text line that is on its way ─────────────────────────────────────────
10
+ //
11
+ // Drawn at the line's own height so the card around it does not move when the
12
+ // words land, and shaped like the answer (a rounded bar about the width of a
13
+ // short address) rather than a spinner: a spinner beside grey text says
14
+ // "busy", a bar where the text goes says "the line is coming" (user decision
15
+ // 2026-09-07). The pin summary and the review card use it while the reverse
16
+ // geocode is out. It pulses the way KeyPeoplePending's ghost roster does, so
17
+ // the SDK has one skeleton language, and holds still under reduced motion.
18
+ // The words still reach assistive tech through the live-region label.
19
+ //
20
+ // Mirrors the web SDK's components/LineSkeleton and Flutter's
21
+ // widgets/line_skeleton.dart.
22
+
23
+ type Variant = React.ComponentProps<typeof MyazaText>['variant'];
24
+
25
+ export function LineSkeleton({
26
+ label,
27
+ variant = 'bodyMedium',
28
+ textStyle,
29
+ barHeight = 10,
30
+ width = '62%',
31
+ style,
32
+ }: {
33
+ /** What a screen reader hears, e.g. "Finding the address…". */
34
+ label: string;
35
+ /** The variant the real line renders with, so the ghost owns its height. */
36
+ variant?: Variant;
37
+ textStyle?: object;
38
+ barHeight?: number;
39
+ /** How much of the line the bar covers; an address, not a paragraph. */
40
+ width?: number | `${number}%`;
41
+ style?: object;
42
+ }): React.ReactElement {
43
+ const { colors } = useTheme();
44
+ const reduceMotion = useReduceMotion();
45
+ const pulse = useRef(new Animated.Value(1)).current;
46
+
47
+ useEffect(() => {
48
+ if (reduceMotion) return undefined;
49
+ const loop = Animated.loop(
50
+ Animated.sequence([
51
+ Animated.timing(pulse, { toValue: 0.45, duration: 600, useNativeDriver: true }),
52
+ Animated.timing(pulse, { toValue: 1, duration: 600, useNativeDriver: true }),
53
+ ]),
54
+ );
55
+ loop.start();
56
+ return () => loop.stop();
57
+ }, [pulse, reduceMotion]);
58
+
59
+ return (
60
+ <View
61
+ accessible
62
+ accessibilityLiveRegion="polite"
63
+ accessibilityLabel={label}
64
+ style={[{ justifyContent: 'center' }, style]}
65
+ >
66
+ {/* An invisible line in the real variant owns the height, so the card
67
+ does not move when the words replace the bar. */}
68
+ <MyazaText variant={variant} style={[textStyle, { opacity: 0 }]} numberOfLines={1}>
69
+ {' '}
70
+ </MyazaText>
71
+ <Animated.View
72
+ pointerEvents="none"
73
+ style={{
74
+ position: 'absolute',
75
+ left: 0,
76
+ width,
77
+ height: barHeight,
78
+ borderRadius: radius.full,
79
+ backgroundColor: colors.primary100,
80
+ opacity: reduceMotion ? 0.7 : pulse,
81
+ }}
82
+ />
83
+ </View>
84
+ );
85
+ }
86
+
87
+ /** The resolved line fading in where the skeleton was, so the swap reads as
88
+ * the answer landing rather than a flicker. Instant under reduced motion. */
89
+ export function LineReveal({ children, style }: { children: React.ReactNode; style?: object }): React.ReactElement {
90
+ const reduceMotion = useReduceMotion();
91
+ const opacity = useRef(new Animated.Value(0)).current;
92
+ useEffect(() => {
93
+ if (reduceMotion) {
94
+ opacity.setValue(1);
95
+ return;
96
+ }
97
+ Animated.timing(opacity, { toValue: 1, duration: 200, useNativeDriver: true }).start();
98
+ }, [opacity, reduceMotion]);
99
+ return <Animated.View style={[{ opacity }, style]}>{children}</Animated.View>;
100
+ }
@@ -0,0 +1,67 @@
1
+ import React from 'react';
2
+ import { Pressable, View } from 'react-native';
3
+
4
+ import { radius } from '../config/theme';
5
+ import { useTheme } from './runtime';
6
+ import { Icon } from './Icon';
7
+ import { MyazaText } from './Typography';
8
+
9
+ // The map's furniture, split from MapPinPicker per the 200-line rule.
10
+
11
+ /** Zoom controls, top-right. Absent on a read-only summary map. */
12
+ export function MapZoomControls({ onZoom }: { onZoom: (delta: number) => void }): React.ReactElement {
13
+ const { colors } = useTheme();
14
+ return (
15
+ <View
16
+ style={{
17
+ position: 'absolute',
18
+ right: 8,
19
+ top: 8,
20
+ borderRadius: radius.sm,
21
+ borderWidth: 1,
22
+ borderColor: colors.border,
23
+ backgroundColor: colors.background,
24
+ overflow: 'hidden',
25
+ }}
26
+ >
27
+ <Pressable
28
+ accessibilityRole="button"
29
+ accessibilityLabel="Zoom in"
30
+ onPress={() => onZoom(1)}
31
+ style={{ padding: 8 }}
32
+ >
33
+ <Icon name="plus" size={16} color={colors.textDark} />
34
+ </Pressable>
35
+ <Pressable
36
+ accessibilityRole="button"
37
+ accessibilityLabel="Zoom out"
38
+ onPress={() => onZoom(-1)}
39
+ style={{ padding: 8, borderTopWidth: 1, borderTopColor: colors.border }}
40
+ >
41
+ <Icon name="minus" size={16} color={colors.textDark} />
42
+ </Pressable>
43
+ </View>
44
+ );
45
+ }
46
+
47
+ /** OSM tile-usage terms require this to be visible wherever the tiles are. */
48
+ export function MapAttribution(): React.ReactElement {
49
+ const { colors } = useTheme();
50
+ return (
51
+ <View
52
+ pointerEvents="none"
53
+ style={{
54
+ position: 'absolute',
55
+ right: 6,
56
+ bottom: 4,
57
+ paddingHorizontal: 4,
58
+ borderRadius: 3,
59
+ backgroundColor: colors.background + 'B3',
60
+ }}
61
+ >
62
+ <MyazaText variant="bodySmall" style={{ fontSize: 10 }} color={colors.textSecondary}>
63
+ © OpenStreetMap contributors
64
+ </MyazaText>
65
+ </View>
66
+ );
67
+ }
@@ -0,0 +1,54 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+ import Svg, { Circle, Rect } from 'react-native-svg';
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // The Myaza map pin — the same drawing as the web SDK's MapPinMarker and
7
+ // Flutter's map_pin_marker.dart, so the built-in OSM picker wears the pin the
8
+ // framed Google map already does. A 1:1 copy of the Bolt reference: head 46px,
9
+ // eye 0.31, stem 0.098 wide showing 0.41 below the head, no casings, flat body
10
+ // (no shadow on the pin itself).
11
+ //
12
+ // The GROUND DOT is the drag-state shadow: while the map pans the pin lifts
13
+ // 17px and the dot appears at the landing point beneath it; when the pin
14
+ // lands the dot goes, leaving the stem tip resting exactly on the picked
15
+ // coordinate. The parent aligns this widget's BOTTOM edge to the map centre.
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export const PIN_LIFT = 17;
19
+
20
+ export function MapPinMarker({
21
+ lifted,
22
+ color,
23
+ }: {
24
+ /** The map is mid-pan: the pin floats and the landing dot shows. */
25
+ lifted: boolean;
26
+ /** The workflow's primary colour (the whole pin wears it). */
27
+ color: string;
28
+ }): React.ReactElement {
29
+ return (
30
+ <View style={{ alignItems: 'center' }} pointerEvents="none">
31
+ <View
32
+ style={{
33
+ position: 'absolute',
34
+ bottom: -2.5,
35
+ width: 11,
36
+ height: 5,
37
+ borderRadius: 3,
38
+ backgroundColor: 'rgba(7, 3, 48, 0.5)',
39
+ opacity: lifted ? 1 : 0,
40
+ }}
41
+ />
42
+ <Svg
43
+ width={46}
44
+ height={65}
45
+ viewBox="0 0 46 65"
46
+ style={{ transform: [{ translateY: lifted ? -PIN_LIFT : 0 }] }}
47
+ >
48
+ <Rect x={20.75} y={42} width={4.5} height={23} rx={2.25} fill={color} />
49
+ <Circle cx={23} cy={23} r={23} fill={color} />
50
+ <Circle cx={23} cy={23} r={7} fill="#FFFFFF" />
51
+ </Svg>
52
+ </View>
53
+ );
54
+ }
@@ -0,0 +1,179 @@
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import { Image, PanResponder, View } from 'react-native';
3
+
4
+ import { radius } from '../config/theme';
5
+ import { useTheme } from './runtime';
6
+ import { MapPinMarker } from './MapPinMarker';
7
+ import { MapAttribution, MapZoomControls } from './MapChrome';
8
+ import {
9
+ MAX_ZOOM,
10
+ MIN_ZOOM,
11
+ panCenter,
12
+ visibleTiles,
13
+ type LatLng,
14
+ } from '../lib/map-tiles';
15
+
16
+ interface MapPinPickerProps {
17
+ /** The confirmed pin, when one exists — the map centres on it. */
18
+ value: LatLng | null;
19
+ /** Fired when the user settles the map (drag end / zoom / recentre). */
20
+ onChange: (pin: LatLng) => void;
21
+ defaultCenter: LatLng;
22
+ defaultZoom: number;
23
+ /** Taller on the pin step, where there has to be room to actually find the
24
+ * building; short on the review step's read-only summary. */
25
+ height?: number;
26
+ /**
27
+ * A read-only summary map: no gestures, no zoom controls, no onChange. The
28
+ * review step draws the pin as a picture of the decision, and a map that can
29
+ * be dragged there invites an edit the step is not offering.
30
+ */
31
+ interactive?: boolean;
32
+ /** Corner rounding — squared off when the map is clipped by a card. */
33
+ cornerRadius?: number;
34
+ /** Zoom when the map has a pin. The pin step goes closer than the default so
35
+ * "is the pin on YOUR building?" is answerable without pinching first. */
36
+ pinZoom?: number;
37
+ }
38
+
39
+ const MAP_HEIGHT = 256;
40
+
41
+ /**
42
+ * A dependency-free OSM slippy map with a FIXED CENTRE PIN — the user moves
43
+ * the map under the pin (the pattern address pickers use on phones), so the
44
+ * pin is always exactly the centre and there is no marker to fumble. Maths in
45
+ * lib/map-tiles.ts; this is only the gesture shell. Mirrors the web SDK's
46
+ * MapPinPicker: tiles for the committed centre, a live translate during the
47
+ * drag, and a commit (+ onChange) on release.
48
+ */
49
+ export function MapPinPicker({
50
+ value,
51
+ onChange,
52
+ defaultCenter,
53
+ defaultZoom,
54
+ height = MAP_HEIGHT,
55
+ interactive = true,
56
+ cornerRadius,
57
+ pinZoom = 17,
58
+ }: MapPinPickerProps) {
59
+ const { colors } = useTheme();
60
+ const [size, setSize] = useState<{ w: number; h: number } | null>(null);
61
+ const [center, setCenter] = useState<LatLng>(value ?? defaultCenter);
62
+ const [zoom, setZoom] = useState(value ? pinZoom : defaultZoom);
63
+ // Live drag offset — applied as a transform so panning stays smooth; the
64
+ // centre (and tile set) commits on release.
65
+ const [drag, setDrag] = useState<{ dx: number; dy: number } | null>(null);
66
+
67
+ // The responder callbacks close over state, so they read it via a ref — a
68
+ // PanResponder is created once and would otherwise pan against the centre
69
+ // and zoom from the first render forever.
70
+ const live = useRef({ center, zoom, onChange });
71
+ live.current = { center, zoom, onChange };
72
+
73
+ // An external recentre (a search pick, or Use my location) moves the map AND
74
+ // zooms in: "is the pin on your building?" cannot be answered at country
75
+ // zoom, and asking someone to pinch first is asking them to do our work.
76
+ // SELF-caused moves round-trip through the same prop (drag commits via
77
+ // onChange → store → new `value`), so a genuinely-external move is one that
78
+ // differs from the centre the map is already showing — without that check,
79
+ // an applicant who zoomed out to find their area was yanked back to pin
80
+ // zoom on every drag. Flutter's picker documents the same hazard.
81
+ useEffect(() => {
82
+ if (!value) return;
83
+ const c = live.current.center;
84
+ if (Math.abs(c.lat - value.lat) < 1e-9 && Math.abs(c.lng - value.lng) < 1e-9) return;
85
+ setCenter(value);
86
+ setZoom((z) => Math.max(z, pinZoom));
87
+ // eslint-disable-next-line react-hooks/exhaustive-deps
88
+ }, [value?.lat, value?.lng]);
89
+
90
+ const pan = useRef(
91
+ PanResponder.create({
92
+ onStartShouldSetPanResponder: () => true,
93
+ onMoveShouldSetPanResponder: (_e, g) => Math.abs(g.dx) + Math.abs(g.dy) > 4,
94
+ onPanResponderMove: (_e, g) => setDrag({ dx: g.dx, dy: g.dy }),
95
+ onPanResponderRelease: (_e, g) => {
96
+ setDrag(null);
97
+ if (g.dx === 0 && g.dy === 0) return;
98
+ const { center: c, zoom: z, onChange: fire } = live.current;
99
+ const next = panCenter(c, z, g.dx, g.dy);
100
+ setCenter(next);
101
+ fire(next);
102
+ },
103
+ onPanResponderTerminate: () => setDrag(null),
104
+ // Android: a parent ScrollView (StickyActions) asks to take a vertical
105
+ // drag over once it passes the scroll slop; refuse, and block the native
106
+ // responder, or the map stops dead a few pixels in while the page moves.
107
+ onPanResponderTerminationRequest: () => false,
108
+ onShouldBlockNativeResponder: () => true,
109
+ }),
110
+ ).current;
111
+
112
+ const zoomBy = (delta: number) => {
113
+ const next = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom + delta));
114
+ if (next === zoom) return;
115
+ setZoom(next);
116
+ onChange(center);
117
+ };
118
+
119
+ const tiles = size ? visibleTiles(center, zoom, size.w, size.h) : [];
120
+
121
+ return (
122
+ <View
123
+ accessibilityLabel={
124
+ interactive ? 'Map. Drag to position the pin on your address.' : 'Map showing your address'
125
+ }
126
+ onLayout={(e) =>
127
+ setSize({ w: e.nativeEvent.layout.width, h: e.nativeEvent.layout.height })
128
+ }
129
+ style={{
130
+ height,
131
+ borderRadius: cornerRadius ?? radius.md,
132
+ borderWidth: cornerRadius === 0 ? 0 : 1,
133
+ borderColor: colors.border,
134
+ backgroundColor: colors.backgroundSecondary,
135
+ overflow: 'hidden',
136
+ }}
137
+ {...(interactive ? pan.panHandlers : {})}
138
+ >
139
+ <View
140
+ style={{
141
+ position: 'absolute',
142
+ top: 0,
143
+ left: 0,
144
+ right: 0,
145
+ bottom: 0,
146
+ transform: drag ? [{ translateX: drag.dx }, { translateY: drag.dy }] : [],
147
+ }}
148
+ >
149
+ {tiles.map((tile) => (
150
+ <Image
151
+ key={tile.key}
152
+ source={{ uri: tile.url }}
153
+ style={{ position: 'absolute', left: tile.left, top: tile.top, width: 256, height: 256 }}
154
+ />
155
+ ))}
156
+ </View>
157
+
158
+ {/* The fixed centre pin — its stem tip on the exact centre, lifted
159
+ with the landing dot beneath it while the map pans. */}
160
+ <View
161
+ pointerEvents="none"
162
+ style={{
163
+ position: 'absolute',
164
+ left: 0,
165
+ right: 0,
166
+ top: 0,
167
+ bottom: height / 2,
168
+ alignItems: 'center',
169
+ justifyContent: 'flex-end',
170
+ }}
171
+ >
172
+ <MapPinMarker lifted={drag != null} color={colors.primary} />
173
+ </View>
174
+
175
+ {interactive ? <MapZoomControls onZoom={zoomBy} /> : null}
176
+ <MapAttribution />
177
+ </View>
178
+ );
179
+ }