@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.
- package/README.md +151 -5
- package/android/build.gradle +35 -3
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +77 -0
- package/app.plugin.js +89 -8
- package/ios/HybridMyazaFaceDetector.swift +16 -3
- package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.cpp +23 -0
- package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.hpp +2 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaFaceDetectorSpec.kt +9 -0
- package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.cpp +8 -0
- package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.hpp +43 -0
- package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.hpp +17 -0
- package/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec.swift +2 -0
- package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec_cxx.swift +31 -0
- package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.cpp +2 -0
- package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.hpp +3 -0
- package/package.json +14 -2
- package/src/MyazaBiometricAuth.tsx +119 -0
- package/src/MyazaKYC.tsx +12 -0
- package/src/components/CountryField.tsx +8 -0
- package/src/components/CountryRegionPicker.tsx +184 -0
- package/src/components/DashedBorder.tsx +55 -0
- package/src/components/DialCodePicker.tsx +53 -67
- package/src/components/DialCodeRow.tsx +102 -0
- package/src/components/FramedMapPicker.tsx +122 -0
- package/src/components/GeoBadge.tsx +34 -0
- package/src/components/Icon.tsx +2 -176
- package/src/components/KycFlow.tsx +60 -32
- package/src/components/LineSkeleton.tsx +100 -0
- package/src/components/MapChrome.tsx +67 -0
- package/src/components/MapPinMarker.tsx +54 -0
- package/src/components/MapPinPicker.tsx +179 -0
- package/src/components/MilestoneTrack.tsx +155 -0
- package/src/components/MultiIdProgress.tsx +107 -0
- package/src/components/MyazaInput.tsx +39 -4
- package/src/components/MyazaSelect.tsx +2 -1
- package/src/components/OptionRow.tsx +22 -3
- package/src/components/OwnershipSlider.tsx +102 -0
- package/src/components/PhoneNumberInput.tsx +45 -4
- package/src/components/PoweredBy.tsx +5 -5
- package/src/components/PresenceBlocks.tsx +153 -0
- package/src/components/StaggerIn.tsx +63 -0
- package/src/components/StepView.tsx +43 -0
- package/src/components/StickyActions.tsx +56 -0
- package/src/components/dialCodeRows.ts +64 -0
- package/src/components/glass/FloatingSheet.tsx +73 -21
- package/src/components/glass/GlassSheet.tsx +14 -2
- package/src/components/icon-map.ts +176 -0
- package/src/components/stepHeaderMeta.tsx +45 -5
- package/src/config/addressCollection.ts +125 -0
- package/src/config/biometricOptions.ts +100 -0
- package/src/config/business.ts +26 -7
- package/src/config/businessDetailsValidity.ts +52 -0
- package/src/config/businessPrefill.ts +72 -0
- package/src/config/businessSteps.ts +20 -3
- package/src/config/consentStep.ts +19 -0
- package/src/config/keyPeople.ts +111 -7
- package/src/config/keyPeopleCorporate.ts +19 -0
- package/src/config/keyPeopleOwnerTypes.ts +12 -0
- package/src/config/keyPeopleOwners.ts +26 -0
- package/src/config/keyPeoplePrefill.ts +138 -0
- package/src/config/keyPeopleSectionDefs.ts +65 -0
- package/src/config/keyPeopleSections.ts +167 -0
- package/src/config/proofOfAddress.ts +54 -11
- package/src/config/regions.ts +24 -0
- package/src/config/registrationHint.ts +1 -1
- package/src/config/stepOrder.ts +100 -6
- package/src/config/theme.ts +8 -2
- package/src/config/uploadLimits.ts +35 -0
- package/src/config/website.ts +36 -0
- package/src/config/workflowMerge.ts +16 -5
- package/src/emrtd/activeAuth.ts +100 -0
- package/src/emrtd/extras.ts +76 -0
- package/src/emrtd/files.ts +5 -0
- package/src/emrtd/index.ts +1 -0
- package/src/emrtd/read.ts +8 -1
- package/src/emrtd/session.ts +41 -0
- package/src/emrtd/stages.ts +11 -0
- package/src/index.ts +54 -1
- package/src/lib/address-current-location.ts +171 -0
- package/src/lib/address-field-modes.ts +119 -0
- package/src/lib/address-flow.ts +172 -0
- package/src/lib/address-helpers.ts +48 -0
- package/src/lib/address-line.ts +76 -0
- package/src/lib/address-pin-move.ts +97 -0
- package/src/lib/address-step-recovery.ts +63 -0
- package/src/lib/authed-image.ts +78 -0
- package/src/lib/biometric-auth.ts +52 -0
- package/src/lib/biometric-copy.ts +51 -0
- package/src/lib/captureRing.ts +83 -0
- package/src/lib/contact-recovery.ts +44 -0
- package/src/lib/country-adoption.ts +89 -0
- package/src/lib/inferred-country.ts +65 -0
- package/src/lib/livenessLayout.ts +49 -0
- package/src/lib/map-frame.ts +159 -0
- package/src/lib/map-tiles.ts +143 -0
- package/src/lib/multi-id.ts +210 -0
- package/src/lib/poa-country-gate.ts +27 -0
- package/src/lib/resubmit.ts +130 -0
- package/src/lib/result-copy.ts +116 -0
- package/src/lib/result-wait.ts +53 -0
- package/src/lib/review-map-surface.ts +26 -0
- package/src/lib/scope.ts +31 -0
- package/src/lib/screen-corners.ts +50 -0
- package/src/lib/selfie-upload-wait.ts +79 -0
- package/src/lib/step-log.ts +24 -3
- package/src/lib/street-view-fov.ts +42 -0
- package/src/lib/use-multi-id-plan.ts +29 -0
- package/src/lib/webview-available.ts +47 -0
- package/src/liveness/useLiveness.ts +6 -1
- package/src/liveness/useModelReady.ts +65 -0
- package/src/liveness/visionCameraFaceDetector.ts +35 -0
- package/src/presence/background-math.ts +100 -0
- package/src/presence/background-store.ts +82 -0
- package/src/presence/background.ts +165 -0
- package/src/presence/foreground-service.ts +193 -0
- package/src/presence/fs.ts +51 -0
- package/src/presence/geofence.ts +41 -0
- package/src/presence/math.ts +44 -0
- package/src/presence/post.ts +40 -0
- package/src/presence/report.ts +87 -0
- package/src/presence/sampler.ts +110 -0
- package/src/presence/status.ts +92 -0
- package/src/presence/store.ts +94 -0
- package/src/presence/tier.ts +36 -0
- package/src/presence/watch-wait.ts +81 -0
- package/src/screens/AddressCountryControl.tsx +115 -0
- package/src/screens/ApplicantRoleStep.tsx +9 -5
- package/src/screens/BusinessCheckPanel.tsx +89 -0
- package/src/screens/BusinessDetailsFields.tsx +112 -0
- package/src/screens/BusinessDetailsStep.tsx +133 -145
- package/src/screens/BusinessDocumentSlot.tsx +5 -5
- package/src/screens/BusinessDocumentsStep.tsx +5 -56
- package/src/screens/BusinessKeyPeopleStep.tsx +103 -71
- package/src/screens/BusinessPickedCard.tsx +86 -0
- package/src/screens/BusinessPickedSection.tsx +51 -0
- package/src/screens/BusinessRegistryPickers.tsx +67 -0
- package/src/screens/BusinessSandboxToggle.tsx +114 -0
- package/src/screens/BusinessSearch.tsx +199 -0
- package/src/screens/BusinessSearchResults.tsx +115 -0
- package/src/screens/CompanyInfoFields.tsx +91 -51
- package/src/screens/ConsentStep.tsx +37 -17
- package/src/screens/ContactActions.tsx +54 -0
- package/src/screens/ContactDestinationField.tsx +5 -0
- package/src/screens/ContactEntryPanel.tsx +75 -0
- package/src/screens/ContactVerificationStep.tsx +43 -42
- package/src/screens/CountrySelectStep.tsx +13 -147
- package/src/screens/IdTypeStep.tsx +12 -5
- package/src/screens/KeepLinksSheet.tsx +88 -0
- package/src/screens/KeyPeopleAwaitCard.tsx +204 -0
- package/src/screens/KeyPeopleAwaitList.tsx +126 -0
- package/src/screens/KeyPeoplePending.tsx +154 -0
- package/src/screens/KeyPeopleSection.tsx +162 -0
- package/src/screens/KeyPeopleSectionsList.tsx +103 -0
- package/src/screens/KeyPeopleUboExemption.tsx +72 -0
- package/src/screens/KeyPersonCard.tsx +36 -4
- package/src/screens/KeyPersonForm.tsx +195 -36
- package/src/screens/KeyPersonKindToggle.tsx +68 -0
- package/src/screens/KeyPersonOwners.tsx +109 -0
- package/src/screens/KeyPersonRoleChips.tsx +68 -0
- package/src/screens/KeyPersonSheet.tsx +92 -108
- package/src/screens/LivenessAvatar.tsx +15 -3
- package/src/screens/LivenessStep.tsx +172 -52
- package/src/screens/NfcStep.tsx +14 -0
- package/src/screens/PoaDocumentTypeList.tsx +66 -0
- package/src/screens/ProofOfAddressParts.tsx +151 -0
- package/src/screens/ProofOfAddressStep.tsx +58 -96
- package/src/screens/SubmittedBadge.tsx +25 -0
- package/src/screens/SubmittedError.tsx +64 -0
- package/src/screens/SubmittedResult.tsx +117 -0
- package/src/screens/SubmittedStep.tsx +82 -119
- package/src/screens/SubmittedSuccess.tsx +127 -0
- package/src/screens/SubmittedWaiting.tsx +45 -0
- package/src/screens/address/AddressEntranceStep.tsx +161 -0
- package/src/screens/address/AddressIntroGate.tsx +148 -0
- package/src/screens/address/AddressMapStub.tsx +50 -0
- package/src/screens/address/AddressPinStep.tsx +178 -0
- package/src/screens/address/AddressReviewStep.tsx +162 -0
- package/src/screens/address/AddressSandboxTabs.tsx +158 -0
- package/src/screens/address/AddressSearchField.tsx +115 -0
- package/src/screens/address/AddressSearchStep.tsx +94 -0
- package/src/screens/address/CurrentLocationRow.tsx +135 -0
- package/src/screens/address/DetailsSheet.tsx +175 -0
- package/src/screens/address/DetailsSheetFields.tsx +184 -0
- package/src/screens/address/EntranceDropzone.tsx +178 -0
- package/src/screens/address/EntranceFraming.tsx +66 -0
- package/src/screens/address/EntrancePills.tsx +60 -0
- package/src/screens/address/FramedStreetView.tsx +155 -0
- package/src/screens/address/IntroDisclosures.tsx +210 -0
- package/src/screens/address/LabelDecisionRow.tsx +113 -0
- package/src/screens/address/PinSummaryRow.tsx +105 -0
- package/src/screens/address/ReviewAddressBand.tsx +111 -0
- package/src/screens/address/ReviewEntranceThumbs.tsx +64 -0
- package/src/screens/address/ReviewMapPicture.tsx +106 -0
- package/src/screens/address/SearchResults.tsx +105 -0
- package/src/screens/address/SearchScreen.tsx +176 -0
- package/src/screens/address/SkipForNow.tsx +43 -0
- package/src/screens/address/StreetViewChrome.tsx +81 -0
- package/src/screens/address/detail-values.ts +22 -0
- package/src/screens/address/fix-source.ts +27 -0
- package/src/screens/address/index.ts +8 -0
- package/src/screens/address/meta.ts +44 -0
- package/src/screens/address/use-address-flow.ts +200 -0
- package/src/screens/address/use-label-pin.ts +80 -0
- package/src/screens/address/use-pin-actions.ts +192 -0
- package/src/screens/biometric/BiometricAuthFlow.tsx +170 -0
- package/src/screens/consent/model.ts +79 -10
- package/src/screens/liveness/CaptureRing.tsx +91 -0
- package/src/screens/liveness/LivenessCamera.tsx +63 -0
- package/src/screens/liveness/LivenessHandover.tsx +43 -0
- package/src/screens/liveness/LivenessOutcome.tsx +4 -3
- package/src/screens/liveness/SelfiePreview.tsx +13 -4
- package/src/screens/liveness/ShutterFlash.tsx +25 -0
- package/src/screens/liveness/index.ts +4 -0
- package/src/screens/liveness/useSelfieUpload.ts +23 -3
- package/src/screens/nfc/NfcReadProgress.tsx +1 -0
- package/src/screens/nfc/NfcScanIllustration.tsx +7 -18
- package/src/screens/useAddressPhotoAttach.ts +77 -0
- package/src/screens/useAwaitingPeople.ts +90 -0
- package/src/screens/useBusinessDocumentAttach.ts +93 -0
- package/src/screens/usePoaAttach.ts +12 -15
- package/src/services/api-biometric.ts +37 -0
- package/src/services/api-types-biometric.ts +41 -0
- package/src/services/api-types.ts +251 -3
- package/src/services/api-verify-types.ts +90 -1
- package/src/services/api.ts +237 -3
- package/src/services/deviceMetadata.ts +1 -1
- package/src/services/errors.ts +10 -2
- package/src/services/location.ts +234 -0
- package/src/services/uploadErrors.ts +30 -0
- package/src/services/workflowGate.ts +8 -0
- package/src/specs/MyazaFaceDetector.nitro.ts +29 -0
- package/src/store/address.ts +97 -0
- package/src/store/businessCheck.ts +113 -0
- package/src/store/derive.ts +44 -4
- package/src/store/kycStore.ts +352 -16
- package/src/store/serverConfig.ts +20 -0
- package/src/store/session.ts +227 -0
- package/src/store/state.ts +289 -2
- package/src/store/submit.ts +80 -13
- package/src/store/submitApplicant.ts +5 -11
- package/src/types/business.ts +23 -2
- package/src/types/config.ts +70 -2
- package/src/types/verification.ts +13 -1
- package/src/types/workflow.ts +112 -1
- 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
|
-
// ──
|
|
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
|
-
[
|
|
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 (
|
|
107
|
-
const steps =
|
|
108
|
-
const idx = steps.indexOf(
|
|
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
|
-
}, [
|
|
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
|
-
|
|
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
|
|
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
|
|
156
|
-
//
|
|
157
|
-
//
|
|
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
|
-
|
|
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
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
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
|
+
}
|