@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
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
import { configScope, isFaceScope } from '../../lib/scope';
|
|
1
2
|
import type { MyazaKYCConfig } from '../../types/config';
|
|
2
3
|
import type { IconName } from '../../components/Icon';
|
|
3
4
|
import { fillTokens } from '../../utils/tokens';
|
|
4
5
|
import { hasEmailVerificationStep, hasPhoneVerificationStep } from '../../config/contact';
|
|
6
|
+
import { hasActiveQuestionnaire } from '../../config/questionnaire';
|
|
7
|
+
import { hasProofOfAddressStep } from '../../config/proofOfAddress';
|
|
8
|
+
import { hasAddressCollectionStep } from '../../config/addressCollection';
|
|
5
9
|
import {
|
|
6
10
|
hasApplicantVerification,
|
|
7
11
|
hasBusinessDocumentsStep,
|
|
@@ -45,15 +49,55 @@ const DEFAULT_DESCRIPTION =
|
|
|
45
49
|
const DEFAULT_BUSINESS_DESCRIPTION =
|
|
46
50
|
'We need to verify your business to comply with regulatory requirements. This process is quick and secure.';
|
|
47
51
|
|
|
52
|
+
const SCOPE_TITLES: Record<string, string> = {
|
|
53
|
+
address: 'Address Verification',
|
|
54
|
+
'biometric-authentication': 'Face Check',
|
|
55
|
+
'biometric-enrollment': 'Face Enrolment',
|
|
56
|
+
questionnaire: 'A Few Questions',
|
|
57
|
+
contact: 'Confirm Your Contact Details',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const SCOPE_DESCRIPTIONS: Record<string, string> = {
|
|
61
|
+
address:
|
|
62
|
+
'We need to confirm your home address to comply with regulatory requirements. This process is quick and secure.',
|
|
63
|
+
'biometric-authentication':
|
|
64
|
+
'A quick face check confirms it is really you. This takes a few seconds and is secure.',
|
|
65
|
+
'biometric-enrollment':
|
|
66
|
+
'A quick selfie sets up face checks for next time, so you will not have to prove your identity again. This takes a few seconds and is secure.',
|
|
67
|
+
questionnaire:
|
|
68
|
+
'A few questions keep your account details up to date and help us comply with regulatory requirements.',
|
|
69
|
+
contact:
|
|
70
|
+
'We need to re-confirm the email address and phone number on your account. This takes a minute and is secure.',
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const SCOPE_BULLETS: Record<string, ConsentProcessStep[]> = {
|
|
74
|
+
address: [
|
|
75
|
+
{ icon: 'map-pin-house', label: 'Pin your home address on a map' },
|
|
76
|
+
{ icon: 'badge-check', label: 'Confirm the details only you can know' },
|
|
77
|
+
],
|
|
78
|
+
'biometric-authentication': [
|
|
79
|
+
{ icon: 'scan-face', label: 'Take a quick selfie with liveness checks' },
|
|
80
|
+
{ icon: 'badge-check', label: 'We match it against your enrolled face' },
|
|
81
|
+
],
|
|
82
|
+
'biometric-enrollment': [
|
|
83
|
+
{ icon: 'scan-face', label: 'Take a quick selfie with liveness checks' },
|
|
84
|
+
{ icon: 'badge-check', label: 'It becomes your face check for next time' },
|
|
85
|
+
],
|
|
86
|
+
questionnaire: [{ icon: 'badge-check', label: 'Answer a few short questions' }],
|
|
87
|
+
contact: [{ icon: 'lock', label: 'Confirm your contact details with a one-time code' }],
|
|
88
|
+
};
|
|
89
|
+
|
|
48
90
|
export function buildConsentModel(config: MyazaKYCConfig): ConsentModel {
|
|
49
91
|
const isBusiness = config.subjectType === 'business';
|
|
92
|
+
const scope = isBusiness ? null : configScope(config);
|
|
93
|
+
const faceScope = isFaceScope(scope);
|
|
50
94
|
const firstName = config.userData?.firstName ?? '';
|
|
51
95
|
|
|
52
96
|
const defaultTitle = firstName
|
|
53
97
|
? `Welcome, ${firstName}`
|
|
54
98
|
: isBusiness
|
|
55
99
|
? 'Business Verification'
|
|
56
|
-
: 'Identity Verification';
|
|
100
|
+
: SCOPE_TITLES[scope ?? ''] ?? 'Identity Verification';
|
|
57
101
|
const title = config.consent?.title
|
|
58
102
|
? fillTokens(config.consent.title, config.userData)
|
|
59
103
|
: defaultTitle;
|
|
@@ -61,40 +105,65 @@ export function buildConsentModel(config: MyazaKYCConfig): ConsentModel {
|
|
|
61
105
|
? fillTokens(config.consent.description, config.userData)
|
|
62
106
|
: isBusiness
|
|
63
107
|
? DEFAULT_BUSINESS_DESCRIPTION
|
|
64
|
-
: DEFAULT_DESCRIPTION;
|
|
108
|
+
: SCOPE_DESCRIPTIONS[scope ?? ''] ?? DEFAULT_DESCRIPTION;
|
|
65
109
|
|
|
66
110
|
// A business flow captures a face only when the applicant verifies their own
|
|
67
111
|
// identity in-flow; an individual flow whenever the selfie step is on.
|
|
68
112
|
const capturesFace = isBusiness
|
|
69
113
|
? hasApplicantVerification(config.business)
|
|
70
|
-
: config.enableSelfie !== false;
|
|
71
|
-
const recordsVideo =
|
|
114
|
+
: faceScope || (!scope && config.enableSelfie !== false);
|
|
115
|
+
const recordsVideo =
|
|
116
|
+
capturesFace || (!isBusiness && !scope && config.enableDocumentCapture !== false);
|
|
72
117
|
|
|
73
118
|
const steps: ConsentProcessStep[] = isBusiness
|
|
74
119
|
? [
|
|
75
120
|
{ icon: 'building-2', label: 'Collect your business registration details' },
|
|
76
121
|
{ icon: 'badge-check', label: 'Verify your business against the official registry' },
|
|
77
122
|
]
|
|
78
|
-
:
|
|
123
|
+
: scope
|
|
124
|
+
? // COPY the catalogue entry: pushing below would otherwise mutate the
|
|
125
|
+
// shared constant, appending one more bullet per re-render.
|
|
126
|
+
[...(SCOPE_BULLETS[scope] ?? [])]
|
|
127
|
+
: [
|
|
79
128
|
{ icon: 'badge-check', label: 'Verify your government-issued ID' },
|
|
80
129
|
{ icon: 'user', label: 'Collect basic personal information' },
|
|
81
130
|
];
|
|
82
|
-
|
|
83
|
-
|
|
131
|
+
// On the CONTACT scope the catalogue bullet already says this — appending
|
|
132
|
+
// the generic line showed "confirm your contact details" twice the moment
|
|
133
|
+
// both channels were on.
|
|
134
|
+
const hasEmail = hasEmailVerificationStep(config.emailVerification);
|
|
135
|
+
const hasPhone = hasPhoneVerificationStep(config.phoneVerification);
|
|
136
|
+
if (scope !== 'contact' && (hasEmail || hasPhone)) {
|
|
137
|
+
const what = hasEmail && hasPhone ? 'email and phone number' : hasEmail ? 'email' : 'phone number';
|
|
138
|
+
steps.push({ icon: 'lock', label: `Confirm your ${what} with a one-time code` });
|
|
84
139
|
}
|
|
85
|
-
if (!isBusiness && config.enableDocumentCapture !== false) {
|
|
140
|
+
if (!isBusiness && !scope && config.enableDocumentCapture !== false) {
|
|
86
141
|
steps.push({ icon: 'scan-line', label: 'Capture a photo of your ID document' });
|
|
87
142
|
}
|
|
88
143
|
// Chip-capable IDs additionally read the document's NFC chip — listed when
|
|
89
144
|
// the flow enables NFC so the user knows to have the physical document to
|
|
90
145
|
// hand (a non-chip ID simply skips the step). Individual flows only; a KYB
|
|
91
146
|
// config can't carry `nfc`.
|
|
92
|
-
if (!isBusiness && config.nfc?.enabled) {
|
|
147
|
+
if (!isBusiness && !scope && config.nfc?.enabled) {
|
|
93
148
|
steps.push({ icon: 'nfc', label: 'Scan your document’s security chip (NFC)' });
|
|
94
149
|
}
|
|
95
|
-
if (!isBusiness && config.enableSelfie !== false) {
|
|
150
|
+
if (!isBusiness && !scope && config.enableSelfie !== false) {
|
|
96
151
|
steps.push({ icon: 'scan-face', label: 'Take a selfie for facial verification' });
|
|
97
152
|
}
|
|
153
|
+
// Post-capture features, in the order the flow runs them. Each is gated on
|
|
154
|
+
// the flow that actually asks for it, and skipped where a scope's own
|
|
155
|
+
// catalogue bullet already covers the same step.
|
|
156
|
+
if (!isBusiness && hasProofOfAddressStep(config.proofOfAddress)) {
|
|
157
|
+
steps.push({ icon: 'file-text', label: 'Upload a proof of address document' });
|
|
158
|
+
}
|
|
159
|
+
if (scope !== 'address' && hasAddressCollectionStep(config.addressCollection)) {
|
|
160
|
+
steps.push({ icon: 'map-pin-house', label: 'Pin your address on a map' });
|
|
161
|
+
}
|
|
162
|
+
// The step-order predicate, not a raw fields check: a questionnaire with
|
|
163
|
+
// questions but enabled: false never runs, so it must not be promised.
|
|
164
|
+
if (scope !== 'questionnaire' && hasActiveQuestionnaire(config.questionnaire)) {
|
|
165
|
+
steps.push({ icon: 'badge-check', label: 'Answer a few short questions' });
|
|
166
|
+
}
|
|
98
167
|
if (isBusiness && hasKeyPeopleCollection(config.business)) {
|
|
99
168
|
steps.push({ icon: 'users', label: "List the company's directors and owners" });
|
|
100
169
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from 'react';
|
|
2
|
+
import Svg, { Circle } from 'react-native-svg';
|
|
3
|
+
import { advanceTarget, easeToward, mixHex } from '../../lib/captureRing';
|
|
4
|
+
|
|
5
|
+
// A single line traced around the camera circle's edge for the length of the
|
|
6
|
+
// liveness test: empty when it starts, closed at the shutter, one way only, no
|
|
7
|
+
// rotation. The frame's own border filling in, not a spinner on top of it.
|
|
8
|
+
//
|
|
9
|
+
// Driven OUTSIDE React. `getTarget` is read every frame in a requestAnimationFrame
|
|
10
|
+
// loop and the arc is written with setNativeProps — no state, no re-render.
|
|
11
|
+
// react-native-reanimated is deliberately not a dependency of this SDK, and a
|
|
12
|
+
// native module is the wrong price for one ring; setNativeProps per frame is
|
|
13
|
+
// the no-render path core React Native offers, and the write is a single prop
|
|
14
|
+
// on a single leaf node. Judge it on a release build on the slowest Android
|
|
15
|
+
// you support.
|
|
16
|
+
|
|
17
|
+
const STROKE = 4; // the frame's own border width
|
|
18
|
+
const GREEN_MS = 300;
|
|
19
|
+
|
|
20
|
+
export function CaptureRing({
|
|
21
|
+
size,
|
|
22
|
+
getTarget,
|
|
23
|
+
color,
|
|
24
|
+
successColor,
|
|
25
|
+
complete,
|
|
26
|
+
}: {
|
|
27
|
+
size: number;
|
|
28
|
+
/** Latest 0..1 progress. Read every frame; must be a stable function. */
|
|
29
|
+
getTarget: () => number;
|
|
30
|
+
color: string;
|
|
31
|
+
successColor: string;
|
|
32
|
+
/** Turns the closed ring green — on the same frame as the shutter. */
|
|
33
|
+
complete: boolean;
|
|
34
|
+
}) {
|
|
35
|
+
const ref = useRef<React.ElementRef<typeof Circle>>(null);
|
|
36
|
+
const targetRef = useRef(0);
|
|
37
|
+
const shownRef = useRef(0);
|
|
38
|
+
const completeRef = useRef(complete);
|
|
39
|
+
const greenSinceRef = useRef<number | null>(null);
|
|
40
|
+
completeRef.current = complete;
|
|
41
|
+
|
|
42
|
+
const r = size / 2 - STROKE / 2;
|
|
43
|
+
const circumference = 2 * Math.PI * r;
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
let frame = 0;
|
|
47
|
+
let last = Date.now();
|
|
48
|
+
const tick = () => {
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
const dt = Math.min((now - last) / 1000, 0.1); // a backgrounded app must not jump
|
|
51
|
+
last = now;
|
|
52
|
+
targetRef.current = advanceTarget(targetRef.current, getTarget());
|
|
53
|
+
shownRef.current = easeToward(shownRef.current, targetRef.current, dt);
|
|
54
|
+
if (completeRef.current && greenSinceRef.current == null) greenSinceRef.current = now;
|
|
55
|
+
const green = greenSinceRef.current == null ? 0 : (now - greenSinceRef.current) / GREEN_MS;
|
|
56
|
+
ref.current?.setNativeProps({
|
|
57
|
+
strokeDashoffset: circumference * (1 - shownRef.current),
|
|
58
|
+
stroke: green > 0 ? mixHex(color, successColor, green) : color,
|
|
59
|
+
});
|
|
60
|
+
frame = requestAnimationFrame(tick);
|
|
61
|
+
};
|
|
62
|
+
frame = requestAnimationFrame(tick);
|
|
63
|
+
return () => cancelAnimationFrame(frame);
|
|
64
|
+
}, [getTarget, circumference, color, successColor]);
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<Svg
|
|
68
|
+
pointerEvents="none"
|
|
69
|
+
style={{ position: 'absolute', top: 0, left: 0 }}
|
|
70
|
+
width={size}
|
|
71
|
+
height={size}
|
|
72
|
+
// Static origin, not motion: the arc starts at twelve o'clock.
|
|
73
|
+
rotation={-90}
|
|
74
|
+
originX={size / 2}
|
|
75
|
+
originY={size / 2}
|
|
76
|
+
>
|
|
77
|
+
<Circle
|
|
78
|
+
ref={ref}
|
|
79
|
+
cx={size / 2}
|
|
80
|
+
cy={size / 2}
|
|
81
|
+
r={r}
|
|
82
|
+
fill="none"
|
|
83
|
+
stroke={color}
|
|
84
|
+
strokeWidth={STROKE}
|
|
85
|
+
strokeLinecap="butt"
|
|
86
|
+
strokeDasharray={[circumference]}
|
|
87
|
+
strokeDashoffset={circumference}
|
|
88
|
+
/>
|
|
89
|
+
</Svg>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { ActivityIndicator, View } from 'react-native';
|
|
3
|
+
import { Camera, type CameraDevice, type CameraViewProps } from 'react-native-vision-camera';
|
|
4
|
+
|
|
5
|
+
// ─── The front camera inside the liveness circle ────────────────────────────
|
|
6
|
+
//
|
|
7
|
+
// The Camera is mounted inside a plain, BORDERLESS wrapper, and that wrapper is
|
|
8
|
+
// the fix for a preview that did not reach the right-hand edge of the circle
|
|
9
|
+
// on Android (Galaxy S24, 2026-09-07: a vertical line one border-width in from
|
|
10
|
+
// the ring, the clip box's ground showing through).
|
|
11
|
+
//
|
|
12
|
+
// VisionCamera installs a "hierarchy fitter" on its Android PreviewView: the
|
|
13
|
+
// moment CameraX adds the SurfaceView, it re-runs `layout(0, 0, w, h)` on the
|
|
14
|
+
// PreviewView so the new child gets measured (React Native never lays native
|
|
15
|
+
// children out on its own). That call keeps the SIZE React Native gave the view
|
|
16
|
+
// and throws away its POSITION — so a Camera that is the direct child of a
|
|
17
|
+
// bordered box snaps from (border, border) back to the box's outer corner. The
|
|
18
|
+
// 3:4 preview buffer overflows the circle vertically and hides the shift on
|
|
19
|
+
// that axis; horizontally it fits exactly, and the missing border-width is the
|
|
20
|
+
// line. iOS has no fitter, which is why only Android showed it.
|
|
21
|
+
//
|
|
22
|
+
// A wrapper with no border sits at (border, border) itself, laid out by React
|
|
23
|
+
// Native, so the fitter's (0, 0) is now the right answer. The wrapper must be
|
|
24
|
+
// `collapsable={false}`: a View that carries nothing but layout is FLATTENED on
|
|
25
|
+
// Android (it never exists natively, its child is re-parented to the
|
|
26
|
+
// grandparent), which put the Camera straight back inside the bordered box
|
|
27
|
+
// and reproduced the line on the first attempt. Never mount VisionCamera's
|
|
28
|
+
// Camera as the direct child of a view with `borderWidth`.
|
|
29
|
+
// Pinned by __tests__/livenessCameraWrapper.test.ts.
|
|
30
|
+
|
|
31
|
+
interface LivenessCameraProps {
|
|
32
|
+
device: CameraDevice | undefined;
|
|
33
|
+
active: boolean;
|
|
34
|
+
outputs: NonNullable<CameraViewProps['outputs']>;
|
|
35
|
+
/** Diameter of the circle, for the Camera's own rounded clip. */
|
|
36
|
+
circle: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function LivenessCamera({ device, active, outputs, circle }: LivenessCameraProps): React.ReactElement {
|
|
40
|
+
return (
|
|
41
|
+
<View style={{ flex: 1 }} collapsable={false}>
|
|
42
|
+
{device ? (
|
|
43
|
+
<Camera
|
|
44
|
+
// borderRadius repeated on the Camera — iOS does not reliably clip
|
|
45
|
+
// a native child to a rounded parent, so the preview's square
|
|
46
|
+
// corners showed through the circle. Same fix the selfie preview
|
|
47
|
+
// and the header brand bar already carry.
|
|
48
|
+
style={{ flex: 1, borderRadius: circle / 2, overflow: 'hidden' }}
|
|
49
|
+
device={device}
|
|
50
|
+
isActive={active}
|
|
51
|
+
outputs={outputs}
|
|
52
|
+
// Mirror the front-camera preview + capture so it reads like a
|
|
53
|
+
// mirror (matches the web SDK's scaleX(-1) video).
|
|
54
|
+
mirrorMode="on"
|
|
55
|
+
/>
|
|
56
|
+
) : (
|
|
57
|
+
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
|
58
|
+
<ActivityIndicator color="#FFFFFF" />
|
|
59
|
+
</View>
|
|
60
|
+
)}
|
|
61
|
+
</View>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
import { useKycConfig } from '../../components/runtime';
|
|
4
|
+
import { configScope } from '../../lib/scope';
|
|
5
|
+
import { describeWaiting } from '../../lib/result-copy';
|
|
6
|
+
import { waitsForResult } from '../../config/biometricOptions';
|
|
7
|
+
import { biometricCopyFor } from '../../lib/biometric-copy';
|
|
8
|
+
import { SubmittedWaiting } from '../SubmittedWaiting';
|
|
9
|
+
|
|
10
|
+
// ─── Handing over without the review ────────────────────────────────────────
|
|
11
|
+
//
|
|
12
|
+
// On the biometric scopes the selfie review (Retake / Continue) is OFF by
|
|
13
|
+
// default (config/biometricOptions.ts): a re-authentication is a few-second
|
|
14
|
+
// check and a review screen is a stop in the middle of it. The step hands
|
|
15
|
+
// over the moment the ring has closed, WITHOUT waiting for the upload: the
|
|
16
|
+
// upload keeps running and reports to the store, and the submitted step waits
|
|
17
|
+
// on that record (lib/selfie-upload-wait.ts). So the person sees one loading
|
|
18
|
+
// screen from the shutter to the verdict, not one per step. The view below
|
|
19
|
+
// exists for the single render between "ready" and the step change, and it
|
|
20
|
+
// is the same screen the submitted step shows, so nothing visibly changes.
|
|
21
|
+
// An upload that fails BEFORE the ring closes still gets the review, whose
|
|
22
|
+
// Try Again is the recovery.
|
|
23
|
+
|
|
24
|
+
/** Advance exactly once, the first render on which `ready` holds. */
|
|
25
|
+
export function useSelfieAutoAdvance(opts: { enabled: boolean; ready: boolean; onAdvance: () => void }): void {
|
|
26
|
+
const advanced = useRef(false);
|
|
27
|
+
const { enabled, ready, onAdvance } = opts;
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (!enabled || !ready || advanced.current) return;
|
|
30
|
+
advanced.current = true;
|
|
31
|
+
onAdvance();
|
|
32
|
+
}, [enabled, ready, onAdvance]);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function LivenessHandover(): React.ReactElement {
|
|
36
|
+
const config = useKycConfig();
|
|
37
|
+
const copy = describeWaiting({
|
|
38
|
+
scope: configScope(config),
|
|
39
|
+
waitsForResult: waitsForResult(config),
|
|
40
|
+
override: biometricCopyFor(config).waiting,
|
|
41
|
+
});
|
|
42
|
+
return <SubmittedWaiting title={copy.title} description={copy.description} />;
|
|
43
|
+
}
|
|
@@ -27,7 +27,8 @@ export function LivenessComplete({
|
|
|
27
27
|
onRetake,
|
|
28
28
|
onContinue,
|
|
29
29
|
}: {
|
|
30
|
-
|
|
30
|
+
/** Null on a restored session: the mediaId survived, the local file did not. */
|
|
31
|
+
selfieUri: string | null;
|
|
31
32
|
upload: SelfieUpload;
|
|
32
33
|
onRetake: () => void;
|
|
33
34
|
onContinue: () => void;
|
|
@@ -40,7 +41,7 @@ export function LivenessComplete({
|
|
|
40
41
|
<SelfiePreview uri={selfieUri} uploading={uploading && !uploadError} />
|
|
41
42
|
{retryInfo && uploading ? (
|
|
42
43
|
<MyazaText variant="bodySmall" color={colors.warning} style={{ textAlign: 'center', marginTop: spacing.sm }}>
|
|
43
|
-
{`Upload failed
|
|
44
|
+
{`Upload failed, retrying (${retryInfo.attempt}/${retryInfo.total})`}
|
|
44
45
|
</MyazaText>
|
|
45
46
|
) : null}
|
|
46
47
|
<View style={{ height: spacing.md }} />
|
|
@@ -51,7 +52,7 @@ export function LivenessComplete({
|
|
|
51
52
|
// Disabled while the retry is in flight, not spinning — the progress
|
|
52
53
|
// indicator belongs in the selfie frame, where the work is.
|
|
53
54
|
disabled={uploading}
|
|
54
|
-
onPress={() => void uploadSelfieAndVideo(selfieUri, videoPathRef.current)}
|
|
55
|
+
onPress={() => selfieUri && void uploadSelfieAndVideo(selfieUri, videoPathRef.current)}
|
|
55
56
|
/>
|
|
56
57
|
) : (
|
|
57
58
|
<View style={{ flexDirection: 'row', gap: spacing.md }}>
|
|
@@ -11,15 +11,24 @@ import { StyleAbsFill } from './constants';
|
|
|
11
11
|
// The captured selfie, shown back while it uploads.
|
|
12
12
|
// ---------------------------------------------------------------------------
|
|
13
13
|
|
|
14
|
-
export function SelfiePreview({ uri, uploading }: { uri: string; uploading?: boolean }): React.ReactElement {
|
|
14
|
+
export function SelfiePreview({ uri, uploading }: { uri: string | null; uploading?: boolean }): React.ReactElement {
|
|
15
15
|
const { colors } = useTheme();
|
|
16
16
|
const S = 200;
|
|
17
17
|
return (
|
|
18
18
|
<View style={{ alignItems: 'center', justifyContent: 'center', alignSelf: 'stretch' }}>
|
|
19
|
-
<View style={{ width: S, height: S, borderRadius: S / 2, borderWidth: 4, backgroundColor: '#111111'
|
|
19
|
+
<View style={{ width: S, height: S, borderRadius: S / 2, borderWidth: 4, backgroundColor: uri ? '#111111' : `${colors.primary}14`, borderColor: `${colors.primary}33`, overflow: 'hidden', alignItems: 'center', justifyContent: 'center' }}>
|
|
20
20
|
{/* borderRadius repeated on the Image — iOS doesn't reliably clip an Image
|
|
21
|
-
child to a rounded parent (same fix as the header brand bar).
|
|
22
|
-
|
|
21
|
+
child to a rounded parent (same fix as the header brand bar). A null
|
|
22
|
+
uri is a restored session whose local file is gone: the uploaded
|
|
23
|
+
selfie is the durable record, so show a completed state rather than
|
|
24
|
+
a broken image. */}
|
|
25
|
+
{uri ? (
|
|
26
|
+
<Image source={{ uri }} style={{ width: '100%', height: '100%', borderRadius: S / 2 }} resizeMode="cover" />
|
|
27
|
+
) : (
|
|
28
|
+
<MyazaText variant="bodySmall" color={colors.textSecondary} style={{ textAlign: 'center', paddingHorizontal: spacing.lg }}>
|
|
29
|
+
Selfie already captured
|
|
30
|
+
</MyazaText>
|
|
31
|
+
)}
|
|
23
32
|
{/* Standard upload loader inside the preview circle (mirrors web/Flutter). */}
|
|
24
33
|
{uploading ? (
|
|
25
34
|
<View style={[StyleAbsFill, { backgroundColor: 'rgba(0,0,0,0.45)', alignItems: 'center', justifyContent: 'center' }]}>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from 'react';
|
|
2
|
+
import { Animated, Easing } from 'react-native';
|
|
3
|
+
import { StyleAbsFill } from './constants';
|
|
4
|
+
|
|
5
|
+
// The shutter: a white flash over the camera circle as the still is taken,
|
|
6
|
+
// fading out over the beat the ring takes to close and go green. Mirrors the
|
|
7
|
+
// web SDK's animate-capture-flash (600ms ease-out). Core Animated on the
|
|
8
|
+
// native driver — opacity only — is the same recipe LightingBanner uses.
|
|
9
|
+
export function ShutterFlash() {
|
|
10
|
+
const opacity = useRef(new Animated.Value(0.85)).current;
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
Animated.timing(opacity, {
|
|
13
|
+
toValue: 0,
|
|
14
|
+
duration: 600,
|
|
15
|
+
easing: Easing.out(Easing.cubic),
|
|
16
|
+
useNativeDriver: true,
|
|
17
|
+
}).start();
|
|
18
|
+
}, [opacity]);
|
|
19
|
+
return (
|
|
20
|
+
<Animated.View
|
|
21
|
+
pointerEvents="none"
|
|
22
|
+
style={[StyleAbsFill, { backgroundColor: '#FFFFFF', opacity }]}
|
|
23
|
+
/>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -3,6 +3,10 @@ export { StyleAbsFill, FRESH_FACE_TIMEOUT_MS, INSTRUCTION_HEIGHT } from './const
|
|
|
3
3
|
export { LightingBanner } from './LightingBanner';
|
|
4
4
|
export { resolveGuidance } from './resolveGuidance';
|
|
5
5
|
export { ProgressDots } from './ProgressDots';
|
|
6
|
+
export { CaptureRing } from './CaptureRing';
|
|
7
|
+
export { ShutterFlash } from './ShutterFlash';
|
|
6
8
|
export { SelfiePreview } from './SelfiePreview';
|
|
7
9
|
export { useSelfieUpload } from './useSelfieUpload';
|
|
8
10
|
export { LivenessComplete, LivenessFailed } from './LivenessOutcome';
|
|
11
|
+
export { LivenessCamera } from './LivenessCamera';
|
|
12
|
+
export { LivenessHandover, useSelfieAutoAdvance } from './LivenessHandover';
|
|
@@ -44,12 +44,29 @@ export function useSelfieUpload(): SelfieUpload {
|
|
|
44
44
|
const config = useKycConfig();
|
|
45
45
|
const api = useKyc((s) => s.api);
|
|
46
46
|
const setMediaId = useKyc((s) => s.setMediaId);
|
|
47
|
+
const setSelfiePreview = useKyc((s) => s.setSelfiePreview);
|
|
48
|
+
// The store-side progress report. The liveness step may hand over before
|
|
49
|
+
// the upload lands (the biometric scopes hide the review), so the submitted
|
|
50
|
+
// step waits on THIS rather than on this hook's local state.
|
|
51
|
+
const setSelfieUpload = useKyc((s) => s.setSelfieUpload);
|
|
52
|
+
const storedPreview = useKyc((s) => s.selfiePreviewUri);
|
|
53
|
+
const storedSelfieId = useKyc((s) => s.mediaIds.selfie);
|
|
47
54
|
|
|
48
|
-
|
|
55
|
+
// Hydrated from the STORE so leaving the step and returning lands on the
|
|
56
|
+
// review screen with the same selfie, exactly as document captures do. The
|
|
57
|
+
// setter writes through, keeping the store the source of truth.
|
|
58
|
+
const [selfieUri, setSelfieUriState] = useState<string | null>(storedPreview);
|
|
59
|
+
const setSelfieUri = useCallback(
|
|
60
|
+
(uri: string | null) => {
|
|
61
|
+
setSelfieUriState(uri);
|
|
62
|
+
setSelfiePreview(uri);
|
|
63
|
+
},
|
|
64
|
+
[setSelfiePreview],
|
|
65
|
+
);
|
|
49
66
|
const [uploading, setUploading] = useState(false);
|
|
50
67
|
const [retryInfo, setRetryInfo] = useState<{ attempt: number; total: number } | null>(null);
|
|
51
68
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
|
52
|
-
const selfieIdRef = useRef<string | null>(null);
|
|
69
|
+
const selfieIdRef = useRef<string | null>(storedSelfieId ?? null);
|
|
53
70
|
const videoPathRef = useRef<string | null>(null);
|
|
54
71
|
|
|
55
72
|
const uploadSelfieAndVideo = useCallback(
|
|
@@ -57,6 +74,7 @@ export function useSelfieUpload(): SelfieUpload {
|
|
|
57
74
|
setUploading(true);
|
|
58
75
|
setUploadError(null);
|
|
59
76
|
setRetryInfo(null);
|
|
77
|
+
setSelfieUpload({ status: 'uploading', message: null });
|
|
60
78
|
const onRetry = (attempt: number, total: number) => setRetryInfo({ attempt, total });
|
|
61
79
|
try {
|
|
62
80
|
const selfieId = await withRetry(
|
|
@@ -81,16 +99,18 @@ export function useSelfieUpload(): SelfieUpload {
|
|
|
81
99
|
}
|
|
82
100
|
setRetryInfo(null);
|
|
83
101
|
setUploading(false);
|
|
102
|
+
setSelfieUpload({ status: 'done', message: null });
|
|
84
103
|
} catch (err) {
|
|
85
104
|
setRetryInfo(null);
|
|
86
105
|
setUploading(false);
|
|
87
106
|
const kycError = mapToKycError(err, 'upload');
|
|
88
107
|
setUploadError(kycError.message);
|
|
108
|
+
setSelfieUpload({ status: 'failed', message: kycError.message });
|
|
89
109
|
toast.show({ variant: 'error', title: 'Upload failed', message: kycError.message });
|
|
90
110
|
safeReportError(config.onError, kycError);
|
|
91
111
|
}
|
|
92
112
|
},
|
|
93
|
-
[api, setMediaId, config.onError, toast],
|
|
113
|
+
[api, setMediaId, setSelfieUpload, config.onError, toast],
|
|
94
114
|
);
|
|
95
115
|
|
|
96
116
|
return {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React, { useEffect, useRef, useState } from 'react';
|
|
2
2
|
import { AccessibilityInfo, Animated, Easing, View } from 'react-native';
|
|
3
|
-
import Svg, { Circle,
|
|
3
|
+
import Svg, { Circle, G, Path, Rect } from 'react-native-svg';
|
|
4
4
|
|
|
5
5
|
import { useTheme } from '../../components/runtime';
|
|
6
6
|
|
|
@@ -112,22 +112,6 @@ export function NfcScanIllustration(): React.ReactElement {
|
|
|
112
112
|
// Same footprint as the web: full width up to 384 (`max-w-sm`), 320:240.
|
|
113
113
|
<View style={{ width: '100%', maxWidth: 384, aspectRatio: 320 / 240, alignSelf: 'center' }}>
|
|
114
114
|
<Svg width="100%" height="100%" viewBox="0 0 320 240">
|
|
115
|
-
<Defs>
|
|
116
|
-
{/* The field falls off with distance — the reason the document has
|
|
117
|
-
to be held CLOSE, said as a gradient instead of a caption. */}
|
|
118
|
-
<RadialGradient
|
|
119
|
-
id="kyc-nfc-field"
|
|
120
|
-
cx={COUPLING.x}
|
|
121
|
-
cy={COUPLING.y}
|
|
122
|
-
r={90}
|
|
123
|
-
gradientUnits="userSpaceOnUse"
|
|
124
|
-
>
|
|
125
|
-
<Stop offset="0" stopColor={colors.primary} stopOpacity={0.18} />
|
|
126
|
-
<Stop offset="0.55" stopColor={colors.primary} stopOpacity={0.06} />
|
|
127
|
-
<Stop offset="1" stopColor={colors.primary} stopOpacity={0} />
|
|
128
|
-
</RadialGradient>
|
|
129
|
-
</Defs>
|
|
130
|
-
|
|
131
115
|
{/* ── The document, tucked BEHIND the phone: portrait, chip, MRZ. ── */}
|
|
132
116
|
<G rotation={7} origin="212, 112">
|
|
133
117
|
<Rect
|
|
@@ -224,7 +208,12 @@ export function NfcScanIllustration(): React.ReactElement {
|
|
|
224
208
|
</G>
|
|
225
209
|
|
|
226
210
|
{/* ── The field, over both objects. ── */}
|
|
227
|
-
|
|
211
|
+
{/* The field falls off with distance — the reason the document has to
|
|
212
|
+
be held CLOSE. Flat concentric steps (no gradients in the flow,
|
|
213
|
+
house rule 2026-08-29). */}
|
|
214
|
+
<Circle cx={COUPLING.x} cy={COUPLING.y} r={90} fill={colors.primary} fillOpacity={0.04} />
|
|
215
|
+
<Circle cx={COUPLING.x} cy={COUPLING.y} r={56} fill={colors.primary} fillOpacity={0.07} />
|
|
216
|
+
<Circle cx={COUPLING.x} cy={COUPLING.y} r={28} fill={colors.primary} fillOpacity={0.12} />
|
|
228
217
|
<Circle cx={COUPLING.x} cy={COUPLING.y} r={4.5} fill={colors.primary} />
|
|
229
218
|
{WAVES.map((w, i) => (
|
|
230
219
|
<AnimatedPath
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { useCallback, useState } from 'react';
|
|
2
|
+
import * as ImagePicker from 'expo-image-picker';
|
|
3
|
+
|
|
4
|
+
import { isAcceptedAddressPhotoMimeType } from '../config/addressCollection';
|
|
5
|
+
import { IMAGE_MAX_BYTES } from '../config/uploadLimits';
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Getting the door photo off the device — the address step's slim sibling of
|
|
9
|
+
// usePoaAttach. Camera or photo library only: the door photo is a PICTURE of a
|
|
10
|
+
// place, so there is no PDF/file case, and each pick closes the source sheet
|
|
11
|
+
// before the picker opens.
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
/** What the picker handed back, so the screen can preview it. */
|
|
15
|
+
export interface AddressPhotoPick {
|
|
16
|
+
uri: string;
|
|
17
|
+
mimeType: string | undefined;
|
|
18
|
+
name: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type UploadFn = (pick: AddressPhotoPick) => Promise<void>;
|
|
22
|
+
|
|
23
|
+
export function useAddressPhotoAttach(
|
|
24
|
+
upload: UploadFn,
|
|
25
|
+
setError: (message: string) => void,
|
|
26
|
+
): {
|
|
27
|
+
pick: () => void;
|
|
28
|
+
sheetOpen: boolean;
|
|
29
|
+
closeSheet: () => void;
|
|
30
|
+
choosePhoto: () => Promise<void>;
|
|
31
|
+
takePhoto: () => Promise<void>;
|
|
32
|
+
} {
|
|
33
|
+
const [sheetOpen, setSheetOpen] = useState(false);
|
|
34
|
+
const pick = useCallback(() => setSheetOpen(true), []);
|
|
35
|
+
const closeSheet = useCallback(() => setSheetOpen(false), []);
|
|
36
|
+
|
|
37
|
+
const accept = useCallback(
|
|
38
|
+
async (asset: ImagePicker.ImagePickerAsset | undefined, fallbackName: string) => {
|
|
39
|
+
if (!asset) return;
|
|
40
|
+
if (typeof asset.fileSize === 'number' && asset.fileSize > IMAGE_MAX_BYTES) {
|
|
41
|
+
setError('Photo is too large (max 5 MB).');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const mimeType = asset.mimeType ?? 'image/jpeg';
|
|
45
|
+
// The picker's media filter is advisory on some platforms, so what came
|
|
46
|
+
// back is re-checked rather than trusted.
|
|
47
|
+
if (!isAcceptedAddressPhotoMimeType(mimeType)) {
|
|
48
|
+
setError('Please choose a photo (JPEG, PNG or WebP).');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
await upload({ uri: asset.uri, mimeType, name: asset.fileName ?? fallbackName });
|
|
52
|
+
},
|
|
53
|
+
[setError, upload],
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const takePhoto = useCallback(async () => {
|
|
57
|
+
setSheetOpen(false);
|
|
58
|
+
const permission = await ImagePicker.requestCameraPermissionsAsync();
|
|
59
|
+
if (!permission.granted) {
|
|
60
|
+
setError('Camera access is needed to photograph the entrance.');
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const result = await ImagePicker.launchCameraAsync({ mediaTypes: ['images'], quality: 1 });
|
|
64
|
+
await accept(result.canceled ? undefined : result.assets[0], 'address-photo.jpg');
|
|
65
|
+
}, [accept, setError]);
|
|
66
|
+
|
|
67
|
+
const choosePhoto = useCallback(async () => {
|
|
68
|
+
setSheetOpen(false);
|
|
69
|
+
const result = await ImagePicker.launchImageLibraryAsync({
|
|
70
|
+
mediaTypes: ['images'],
|
|
71
|
+
quality: 1,
|
|
72
|
+
});
|
|
73
|
+
await accept(result.canceled ? undefined : result.assets[0], 'address-photo.jpg');
|
|
74
|
+
}, [accept]);
|
|
75
|
+
|
|
76
|
+
return { pick, sheetOpen, closeSheet, choosePhoto, takePhoto };
|
|
77
|
+
}
|