@myazahq/kyc-sdk-react-native 2.5.0 → 3.0.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 +277 -7
- package/android/build.gradle +30 -16
- package/android/consumer-rules.pro +22 -0
- package/android/src/main/AndroidManifest.xml +17 -6
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +27 -70
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizer.kt +14 -0
- package/android/src/main/java/com/margelo/nitro/myazakyc/MlKitModelReadiness.kt +154 -0
- package/app.plugin.js +146 -10
- package/ios/HybridMyazaFaceDetector.swift +8 -3
- package/ios/HybridMyazaTextRecognizer.swift +8 -0
- package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.cpp +21 -0
- package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.hpp +2 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizerSpec.kt +8 -0
- package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.hpp +16 -0
- package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec.swift +2 -0
- package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec_cxx.swift +31 -0
- package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.cpp +2 -0
- package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.hpp +2 -0
- package/package.json +30 -9
- package/src/MyazaKYC.tsx +4 -11
- package/src/assets/fonts/Karla_400Regular.ttf +0 -0
- package/src/assets/fonts/Karla_500Medium.ttf +0 -0
- package/src/assets/fonts/Karla_600SemiBold.ttf +0 -0
- package/src/assets/fonts/Karla_700Bold.ttf +0 -0
- package/src/assets/fonts/OFL-Karla.txt +93 -0
- package/src/assets/fonts/OFL-SpaceGrotesk.txt +93 -0
- package/src/assets/fonts/README.md +12 -0
- package/src/assets/fonts/SpaceGrotesk_500Medium.ttf +0 -0
- package/src/assets/fonts/SpaceGrotesk_600SemiBold.ttf +0 -0
- package/src/assets/fonts/SpaceGrotesk_700Bold.ttf +0 -0
- package/src/capture/useAutoCapture.ts +8 -1
- package/src/components/CountryField.tsx +8 -0
- package/src/components/CountryRegionPicker.tsx +184 -0
- package/src/components/DialCodePicker.tsx +47 -68
- package/src/components/DialCodeRow.tsx +102 -0
- package/src/components/DocumentCropper.tsx +30 -24
- package/src/components/DocumentReview.tsx +11 -1
- package/src/components/DocumentReviewSide.tsx +6 -2
- package/src/components/DocumentReviewZoom.tsx +5 -1
- package/src/components/FramedMapPicker.tsx +122 -0
- package/src/components/GeoBadge.tsx +34 -0
- package/src/components/Icon.tsx +10 -192
- package/src/components/KycFlow.tsx +69 -34
- 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/MyazaButton.tsx +9 -0
- package/src/components/MyazaInput.tsx +17 -1
- package/src/components/OptionRow.tsx +22 -3
- package/src/components/PhoneNumberInput.tsx +8 -0
- package/src/components/PoweredBy.tsx +5 -5
- package/src/components/PresenceBlocks.tsx +153 -0
- package/src/components/RequiredDocumentPill.tsx +114 -0
- package/src/components/StepHeader.tsx +23 -3
- package/src/components/StepView.tsx +17 -0
- package/src/components/StickyActions.tsx +56 -0
- package/src/components/dialCodeRows.ts +64 -0
- package/src/components/documentReviewCopy.ts +39 -0
- package/src/components/fonts.ts +14 -17
- package/src/components/icon-map.ts +176 -0
- package/src/components/stepHeaderMeta.tsx +48 -5
- package/src/config/addressCollection.ts +125 -0
- package/src/config/biometricOptions.ts +100 -0
- package/src/config/business.ts +20 -1
- package/src/config/businessSteps.ts +8 -2
- package/src/config/consentStep.ts +19 -0
- package/src/config/documentCaptureMethods.ts +31 -0
- package/src/config/proofOfAddress.ts +54 -11
- package/src/config/regions.ts +24 -0
- package/src/config/stepOrder.ts +79 -5
- package/src/config/uploadLimits.ts +35 -0
- package/src/config/workflowMerge.ts +13 -5
- package/src/index.ts +49 -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-copy.ts +51 -0
- package/src/lib/captureRing.ts +83 -0
- package/src/lib/country-adoption.ts +89 -0
- package/src/lib/documentCaptureCheck.ts +136 -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/model-ready.ts +82 -0
- package/src/lib/poa-country-gate.ts +27 -0
- package/src/lib/prime-models.ts +50 -0
- package/src/lib/resubmit.ts +36 -7
- 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/selfie-sharpness.ts +211 -0
- package/src/lib/selfie-upload-wait.ts +79 -0
- package/src/lib/street-view-fov.ts +42 -0
- package/src/lib/webview-available.ts +47 -0
- package/src/liveness/avatarSource.ts +59 -0
- package/src/liveness/useLiveness.ts +6 -1
- package/src/liveness/useModelReady.ts +9 -55
- package/src/mrz/extract.ts +71 -4
- package/src/mrz/textRecognizer.ts +36 -0
- package/src/mrz/useTextModelReady.ts +21 -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/BusinessDetailsFields.tsx +3 -0
- package/src/screens/BusinessDetailsStep.tsx +2 -0
- package/src/screens/BusinessDocumentSlot.tsx +3 -2
- package/src/screens/BusinessDocumentsStep.tsx +4 -55
- package/src/screens/CompanyInfoFields.tsx +6 -1
- package/src/screens/ConsentStep.tsx +6 -17
- package/src/screens/ContactActions.tsx +54 -0
- package/src/screens/ContactDestinationField.tsx +4 -0
- package/src/screens/ContactEntryPanel.tsx +75 -0
- package/src/screens/ContactVerificationStep.tsx +26 -56
- package/src/screens/CountrySelectStep.tsx +13 -147
- package/src/screens/DocumentCaptureStep.tsx +177 -93
- package/src/screens/LivenessAvatar.tsx +33 -16
- package/src/screens/LivenessStep.tsx +188 -49
- package/src/screens/MrzScanView.tsx +33 -0
- package/src/screens/PoaDocumentTypeList.tsx +66 -0
- package/src/screens/ProofOfAddressParts.tsx +151 -0
- package/src/screens/ProofOfAddressStep.tsx +58 -99
- 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 +71 -164
- 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/consent/model.ts +79 -10
- package/src/screens/document/CaptureCheckNotice.tsx +77 -0
- package/src/screens/document/UploadPhase.tsx +177 -0
- package/src/screens/document/useDocumentCamera.ts +109 -0
- 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 +8 -4
- package/src/screens/liveness/SelfiePreview.tsx +31 -7
- 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/NfcScanIllustration.tsx +7 -18
- package/src/screens/useAddressPhotoAttach.ts +77 -0
- package/src/screens/useBusinessDocumentAttach.ts +93 -0
- package/src/screens/usePoaAttach.ts +12 -15
- package/src/services/api-types.ts +119 -1
- package/src/services/api-verify-types.ts +37 -0
- package/src/services/api.ts +149 -3
- package/src/services/deviceMetadata.ts +1 -1
- package/src/services/location.ts +234 -0
- package/src/services/mediaCompress.ts +23 -5
- package/src/services/uploadErrors.ts +30 -0
- package/src/services/workflowGate.ts +7 -0
- package/src/specs/MyazaTextRecognizer.nitro.ts +29 -0
- package/src/store/address.ts +97 -0
- package/src/store/derive.ts +29 -4
- package/src/store/kycStore.ts +120 -5
- package/src/store/serverConfig.ts +18 -0
- package/src/store/session.ts +23 -4
- package/src/store/state.ts +134 -0
- package/src/store/submit.ts +18 -1
- package/src/types/config.ts +63 -1
- package/src/types/verification.ts +12 -0
- package/src/types/workflow.ts +97 -1
- package/src/assets/liveness/Blink.gif +0 -0
- package/src/assets/liveness/Nod.gif +0 -0
- package/src/assets/liveness/Smile.gif +0 -0
- package/src/assets/liveness/Turn.gif +0 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import React, { useMemo } from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { radius, spacing } from '../config/theme';
|
|
5
|
+
import { useKyc, useKycConfig, useKycStore, useTheme } from '../components/runtime';
|
|
6
|
+
import { MyazaText } from '../components/Typography';
|
|
7
|
+
import { CountryField } from '../components/CountryField';
|
|
8
|
+
import { CountryFlag } from '../components/CountryFlag';
|
|
9
|
+
import { ALL_REGION_CODES, regionCountryName } from '../config/regions';
|
|
10
|
+
import { configScope } from '../lib/scope';
|
|
11
|
+
import { inferredCountry } from '../lib/inferred-country';
|
|
12
|
+
import type { SupportedCountry } from '../types/config';
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// The declared-country control for ADDRESS-SCOPED flows — mounted on the
|
|
16
|
+
// Proof of Address step (the Didit PoA model: the applicant names their
|
|
17
|
+
// market, then uploads the document). Mirrors the web SDK's
|
|
18
|
+
// steps/address/AddressCountryControl: the pick drives the document kinds on
|
|
19
|
+
// offer, the search filter, the map's opening view, the PoA vendor market,
|
|
20
|
+
// and rides the submission as the verification's country. The pin stays the
|
|
21
|
+
// ground truth regardless.
|
|
22
|
+
//
|
|
23
|
+
// The picker is the house country sheet (CountryField) in its region-grouped
|
|
24
|
+
// mode, with the inferred country (server geo first, device locale second)
|
|
25
|
+
// pinned on top as "Your location" — the web's region menu, in the sheet the
|
|
26
|
+
// rest of the mobile flow uses (user decision 2026-09-06).
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
/** What the picker offers: the org's accepted list, else every country. */
|
|
30
|
+
export function poaOfferedCountries(configured: string[] | undefined): string[] {
|
|
31
|
+
const list = (configured ?? [])
|
|
32
|
+
.map((c) => c.toUpperCase())
|
|
33
|
+
.filter((c) => ALL_REGION_CODES.includes(c));
|
|
34
|
+
return list.length > 0 ? list : ALL_REGION_CODES;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function AddressCountryControl(): React.ReactElement | null {
|
|
38
|
+
const config = useKycConfig();
|
|
39
|
+
const store = useKycStore();
|
|
40
|
+
const { colors } = useTheme();
|
|
41
|
+
const selected = useKyc((s) => s.selectedCountry);
|
|
42
|
+
const geo = useKyc((s) => s.serverConfig.geoCountry);
|
|
43
|
+
|
|
44
|
+
const scoped = configScope(config) === 'address';
|
|
45
|
+
const offered = useMemo(
|
|
46
|
+
() => poaOfferedCountries(config.proofOfAddress?.countries),
|
|
47
|
+
[config.proofOfAddress?.countries],
|
|
48
|
+
);
|
|
49
|
+
const options = useMemo(
|
|
50
|
+
() => offered.map((code) => ({ code, name: regionCountryName(code) })),
|
|
51
|
+
[offered],
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
if (!scoped) return null;
|
|
55
|
+
|
|
56
|
+
// Only a country the applicant PICKED shows as selected: the address scope
|
|
57
|
+
// has no seeded country to show (web's AddressCountryControl, same rule), and
|
|
58
|
+
// showing config.country here while the attachment area's flag read
|
|
59
|
+
// `selectedCountry` had the control and the drop zone disagreeing.
|
|
60
|
+
const value = selected ?? null;
|
|
61
|
+
const pick = (code: string) => store.getState().setCountry(code as SupportedCountry);
|
|
62
|
+
|
|
63
|
+
// One accepted country = nothing to pick. Show it as a settled fact rather
|
|
64
|
+
// than a sheet that could only ever re-answer itself.
|
|
65
|
+
if (offered.length === 1) {
|
|
66
|
+
const only = offered[0]!;
|
|
67
|
+
// The label sits ABOVE the field, as it does on every other input (user
|
|
68
|
+
// decision 2026-09-05) — the read-only box is CountryField's trigger
|
|
69
|
+
// without the chevron, so the settled and pickable states line up.
|
|
70
|
+
return (
|
|
71
|
+
<View style={{ marginBottom: spacing.lg }}>
|
|
72
|
+
<MyazaText variant="bodySmall" style={{ fontWeight: '600', marginBottom: spacing.xs }}>
|
|
73
|
+
Country
|
|
74
|
+
</MyazaText>
|
|
75
|
+
<View
|
|
76
|
+
style={{
|
|
77
|
+
flexDirection: 'row',
|
|
78
|
+
alignItems: 'center',
|
|
79
|
+
paddingHorizontal: spacing.md,
|
|
80
|
+
paddingVertical: spacing.md,
|
|
81
|
+
borderRadius: radius.sm,
|
|
82
|
+
borderWidth: 1,
|
|
83
|
+
borderColor: colors.border,
|
|
84
|
+
backgroundColor: colors.backgroundSecondary,
|
|
85
|
+
}}
|
|
86
|
+
>
|
|
87
|
+
<CountryFlag country={only} size={20} />
|
|
88
|
+
<View style={{ width: spacing.sm }} />
|
|
89
|
+
<MyazaText variant="body" style={{ flex: 1 }} numberOfLines={1}>
|
|
90
|
+
{regionCountryName(only)}
|
|
91
|
+
</MyazaText>
|
|
92
|
+
</View>
|
|
93
|
+
</View>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<View style={{ marginBottom: spacing.lg }}>
|
|
99
|
+
<MyazaText variant="bodySmall" style={{ fontWeight: '600', marginBottom: spacing.xs }}>
|
|
100
|
+
Country
|
|
101
|
+
</MyazaText>
|
|
102
|
+
<CountryField
|
|
103
|
+
value={value}
|
|
104
|
+
options={options}
|
|
105
|
+
onChange={pick}
|
|
106
|
+
// The inferred country pinned on top of the sheet; the sheet itself
|
|
107
|
+
// drops it when the org's list does not carry it.
|
|
108
|
+
geoCountry={inferredCountry(geo)}
|
|
109
|
+
grouped
|
|
110
|
+
placeholder="Select country"
|
|
111
|
+
searchPlaceholder="Search countries"
|
|
112
|
+
/>
|
|
113
|
+
</View>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -26,6 +26,7 @@ export function BusinessDetailsFields({
|
|
|
26
26
|
formatOk,
|
|
27
27
|
requireName,
|
|
28
28
|
country,
|
|
29
|
+
geoCountry,
|
|
29
30
|
modes,
|
|
30
31
|
showCompanyInfo,
|
|
31
32
|
showContactEmail,
|
|
@@ -38,6 +39,7 @@ export function BusinessDetailsFields({
|
|
|
38
39
|
formatOk: boolean;
|
|
39
40
|
requireName: boolean;
|
|
40
41
|
country: string;
|
|
42
|
+
geoCountry?: string | null;
|
|
41
43
|
modes: Record<CompanyInfoField, CompanyInfoMode>;
|
|
42
44
|
showCompanyInfo: boolean;
|
|
43
45
|
showContactEmail: boolean;
|
|
@@ -84,6 +86,7 @@ export function BusinessDetailsFields({
|
|
|
84
86
|
values={business}
|
|
85
87
|
modes={modes}
|
|
86
88
|
country={country}
|
|
89
|
+
geoCountry={geoCountry}
|
|
87
90
|
onChange={(field, value) => onChange(field, value)}
|
|
88
91
|
/>
|
|
89
92
|
</>
|
|
@@ -45,6 +45,7 @@ export function BusinessDetailsStep(): React.ReactElement {
|
|
|
45
45
|
const [phase, setPhase] = useState<'pick' | 'details'>('pick');
|
|
46
46
|
// Production never shows the test-result toggle, and never honours a pin.
|
|
47
47
|
const isSandbox = useKyc((s) => s.serverConfig.environment) !== 'PRODUCTION';
|
|
48
|
+
const geoCountry = useKyc((s) => s.serverConfig.geoCountry);
|
|
48
49
|
|
|
49
50
|
const workflowBusiness = config.business;
|
|
50
51
|
const countries = useMemo(() => businessCountriesFor(workflowBusiness), [workflowBusiness]);
|
|
@@ -171,6 +172,7 @@ export function BusinessDetailsStep(): React.ReactElement {
|
|
|
171
172
|
formatOk={formatOk}
|
|
172
173
|
requireName={nameRequired}
|
|
173
174
|
country={country}
|
|
175
|
+
geoCountry={geoCountry}
|
|
174
176
|
modes={modes}
|
|
175
177
|
showCompanyInfo={showCompanyInfo}
|
|
176
178
|
showContactEmail={showContactEmail}
|
|
@@ -2,6 +2,7 @@ import React from 'react';
|
|
|
2
2
|
import { ActivityIndicator, Image, Pressable, View } from 'react-native';
|
|
3
3
|
|
|
4
4
|
import { radius, spacing } from '../config/theme';
|
|
5
|
+
import { UPLOAD_HINT } from '../config/uploadLimits';
|
|
5
6
|
import { useTheme } from '../components/runtime';
|
|
6
7
|
import { MyazaText } from '../components/Typography';
|
|
7
8
|
import { Icon } from '../components/Icon';
|
|
@@ -11,7 +12,7 @@ import { DashedBorder } from '../components/DashedBorder';
|
|
|
11
12
|
// One business-document upload slot — mirrors the web SDK's
|
|
12
13
|
// BusinessDocumentSlot 1:1: empty, it is a dashed full-width tap target
|
|
13
14
|
// (upload glyph — a spinner while uploading — label with a red * when
|
|
14
|
-
// required,
|
|
15
|
+
// required, the shared upload hint); filled, a solid card showing a 48px
|
|
15
16
|
// THUMBNAIL of what was actually picked (a muted file tile for PDFs, a check
|
|
16
17
|
// when the preview didn't survive a remount — web's exact fallback ladder),
|
|
17
18
|
// the file's name over the slot label, Replace, and remove. Showing the file
|
|
@@ -158,7 +159,7 @@ export function BusinessDocumentSlot({
|
|
|
158
159
|
) : null}
|
|
159
160
|
</MyazaText>
|
|
160
161
|
<MyazaText variant="bodySmall" color={colors.textMuted}>
|
|
161
|
-
{uploading ? 'Uploading…' :
|
|
162
|
+
{uploading ? 'Uploading…' : UPLOAD_HINT}
|
|
162
163
|
</MyazaText>
|
|
163
164
|
</View>
|
|
164
165
|
</Pressable>
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import React, { useCallback, useMemo, useState } from 'react';
|
|
2
2
|
import { View } from 'react-native';
|
|
3
|
-
import * as ImagePicker from 'expo-image-picker';
|
|
4
3
|
|
|
5
4
|
import { spacing } from '../config/theme';
|
|
6
5
|
import { useKyc, useKycConfig, useKycStore, useTheme } from '../components/runtime';
|
|
@@ -11,8 +10,7 @@ import { BusinessDocumentSlot } from './BusinessDocumentSlot';
|
|
|
11
10
|
import { withRetry } from '../services/retry';
|
|
12
11
|
import { compressDocumentImage } from '../services/mediaCompress';
|
|
13
12
|
import { resolveBusinessDocumentTypes, type ResolvedBusinessDocumentType } from '../config/businessSteps';
|
|
14
|
-
import {
|
|
15
|
-
import { loadDocumentPicker } from '../services/documentPicker';
|
|
13
|
+
import { useBusinessDocumentAttach } from './useBusinessDocumentAttach';
|
|
16
14
|
|
|
17
15
|
// ---------------------------------------------------------------------------
|
|
18
16
|
// Supporting company documents (certificate of incorporation, MEMART, …).
|
|
@@ -26,7 +24,8 @@ import { loadDocumentPicker } from '../services/documentPicker';
|
|
|
26
24
|
// Layout mirrors the web SDK 1:1: the header carries the description, each
|
|
27
25
|
// document is a dashed upload slot (see BusinessDocumentSlot), and Continue is
|
|
28
26
|
// disabled until every required slot is filled. Tapping a slot opens the
|
|
29
|
-
// photo/file source sheet — the mobile stand-in for web's file input
|
|
27
|
+
// photo/file source sheet — the mobile stand-in for web's file input; the
|
|
28
|
+
// pickers themselves live in useBusinessDocumentAttach.
|
|
30
29
|
// ---------------------------------------------------------------------------
|
|
31
30
|
|
|
32
31
|
export const businessDocumentsMeta = {
|
|
@@ -79,57 +78,7 @@ export function BusinessDocumentsStep(): React.ReactElement {
|
|
|
79
78
|
[store],
|
|
80
79
|
);
|
|
81
80
|
|
|
82
|
-
const takePhoto =
|
|
83
|
-
async (slot: ResolvedBusinessDocumentType) => {
|
|
84
|
-
const permission = await ImagePicker.requestCameraPermissionsAsync();
|
|
85
|
-
if (!permission.granted) {
|
|
86
|
-
setError('Camera access is needed to photograph the document.');
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
|
-
const result = await ImagePicker.launchCameraAsync({ mediaTypes: ['images'], quality: 1 });
|
|
90
|
-
const asset = result.canceled ? undefined : result.assets[0];
|
|
91
|
-
if (asset) {
|
|
92
|
-
// A FRIENDLY name, not the picker's temp junk — camera/library assets
|
|
93
|
-
// carry generated names; the slot key says what the file IS.
|
|
94
|
-
await attach(slot, asset.uri, asset.mimeType ?? 'image/jpeg', `${slot.key}.jpg`);
|
|
95
|
-
}
|
|
96
|
-
},
|
|
97
|
-
[attach],
|
|
98
|
-
);
|
|
99
|
-
|
|
100
|
-
const choosePhoto = useCallback(
|
|
101
|
-
async (slot: ResolvedBusinessDocumentType) => {
|
|
102
|
-
const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], quality: 1 });
|
|
103
|
-
const asset = result.canceled ? undefined : result.assets[0];
|
|
104
|
-
if (asset) {
|
|
105
|
-
await attach(slot, asset.uri, asset.mimeType ?? 'image/jpeg', `${slot.key}.jpg`);
|
|
106
|
-
}
|
|
107
|
-
},
|
|
108
|
-
[attach],
|
|
109
|
-
);
|
|
110
|
-
|
|
111
|
-
const chooseFile = useCallback(
|
|
112
|
-
async (slot: ResolvedBusinessDocumentType) => {
|
|
113
|
-
const picker = loadDocumentPicker();
|
|
114
|
-
if (!picker) {
|
|
115
|
-
setError('Choosing a file is not available in this app. Please photograph the document.');
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
const result = await picker.getDocumentAsync({
|
|
119
|
-
type: [...POA_ACCEPTED_MIME_TYPES],
|
|
120
|
-
copyToCacheDirectory: true,
|
|
121
|
-
multiple: false,
|
|
122
|
-
});
|
|
123
|
-
const asset = result.canceled ? undefined : result.assets?.[0];
|
|
124
|
-
if (!asset) return;
|
|
125
|
-
if (!isAcceptedPoaMimeType(asset.mimeType)) {
|
|
126
|
-
setError('Please choose a photo (JPEG, PNG or WebP) or a PDF.');
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
129
|
-
await attach(slot, asset.uri, asset.mimeType, asset.name ?? slot.key);
|
|
130
|
-
},
|
|
131
|
-
[attach],
|
|
132
|
-
);
|
|
81
|
+
const { takePhoto, choosePhoto, chooseFile } = useBusinessDocumentAttach(attach, setError);
|
|
133
82
|
|
|
134
83
|
const handleContinue = (): void => {
|
|
135
84
|
if (missing.length > 0 || busySlot !== null) return;
|
|
@@ -45,12 +45,16 @@ export function CompanyInfoFields({
|
|
|
45
45
|
values,
|
|
46
46
|
modes,
|
|
47
47
|
country,
|
|
48
|
+
geoCountry,
|
|
48
49
|
onChange,
|
|
49
50
|
}: {
|
|
50
51
|
values: BusinessState;
|
|
51
52
|
modes: Record<CompanyInfoField, CompanyInfoMode>;
|
|
52
53
|
/** Seeds the phone dial code: the company's country of registration. */
|
|
53
54
|
country?: string;
|
|
55
|
+
/** The visitor's IP country: the dial-code seed of last resort, and the
|
|
56
|
+
* row pinned to the top of the picker. */
|
|
57
|
+
geoCountry?: string | null;
|
|
54
58
|
onChange: (field: CompanyInfoField, value: string) => void;
|
|
55
59
|
}): React.ReactElement | null {
|
|
56
60
|
const { colors } = useTheme();
|
|
@@ -88,7 +92,8 @@ export function CompanyInfoFields({
|
|
|
88
92
|
// register's number when the lookup returned one.
|
|
89
93
|
<PhoneNumberInput
|
|
90
94
|
value={value}
|
|
91
|
-
defaultCountry={country}
|
|
95
|
+
defaultCountry={country || geoCountry || undefined}
|
|
96
|
+
geoCountry={geoCountry}
|
|
92
97
|
onChange={({ e164 }) => onChange('phone', e164)}
|
|
93
98
|
/>
|
|
94
99
|
) : f.kind === 'date' ? (
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Linking, Pressable, View } from 'react-native';
|
|
3
|
-
import Svg, { Circle
|
|
3
|
+
import Svg, { Circle } from 'react-native-svg';
|
|
4
4
|
|
|
5
5
|
import { radius, spacing } from '../config/theme';
|
|
6
6
|
import { PRIVACY_URL, TERMS_URL } from '../config/brand';
|
|
@@ -81,21 +81,10 @@ export function ConsentStep(): React.ReactElement {
|
|
|
81
81
|
elevation: 6,
|
|
82
82
|
}}
|
|
83
83
|
>
|
|
84
|
-
{/*
|
|
85
|
-
|
|
86
|
-
stop is the SAME primary at 70% opacity rather than a
|
|
87
|
-
pre-blended colour: composited over the sheet it produces
|
|
88
|
-
Flutter's `alphaBlend(primary@0.7, background)` exactly, and it
|
|
89
|
-
keeps working when the consumer overrides primaryColor or the
|
|
90
|
-
theme flips, which a hardcoded blend would not. */}
|
|
84
|
+
{/* Solid primary badge — no gradients in the flow (house rule
|
|
85
|
+
2026-08-29, applied across all three SDKs in lockstep). */}
|
|
91
86
|
<Svg width={56} height={56} style={{ position: 'absolute' }}>
|
|
92
|
-
<
|
|
93
|
-
<LinearGradient id="myazaShieldHero" x1="0" y1="0" x2="1" y2="1">
|
|
94
|
-
<Stop offset="0" stopColor={colors.primary} stopOpacity={1} />
|
|
95
|
-
<Stop offset="1" stopColor={colors.primary} stopOpacity={0.7} />
|
|
96
|
-
</LinearGradient>
|
|
97
|
-
</Defs>
|
|
98
|
-
<Circle cx={28} cy={28} r={28} fill="url(#myazaShieldHero)" />
|
|
87
|
+
<Circle cx={28} cy={28} r={28} fill={colors.primary} />
|
|
99
88
|
</Svg>
|
|
100
89
|
<Icon name="shield" size={28} color={colors.onPrimary} />
|
|
101
90
|
</View>
|
|
@@ -153,7 +142,7 @@ export function ConsentStep(): React.ReactElement {
|
|
|
153
142
|
<MyazaText
|
|
154
143
|
variant="bodySmall"
|
|
155
144
|
color={colors.textDark}
|
|
156
|
-
style={{ fontWeight: '
|
|
145
|
+
style={{ fontWeight: '500', textDecorationLine: 'underline' }}
|
|
157
146
|
onPress={() => void Linking.openURL(TERMS_URL).catch(() => undefined)}
|
|
158
147
|
>
|
|
159
148
|
End User Terms
|
|
@@ -162,7 +151,7 @@ export function ConsentStep(): React.ReactElement {
|
|
|
162
151
|
<MyazaText
|
|
163
152
|
variant="bodySmall"
|
|
164
153
|
color={colors.textDark}
|
|
165
|
-
style={{ fontWeight: '
|
|
154
|
+
style={{ fontWeight: '500', textDecorationLine: 'underline' }}
|
|
166
155
|
onPress={() => void Linking.openURL(PRIVACY_URL).catch(() => undefined)}
|
|
167
156
|
>
|
|
168
157
|
Privacy Policy
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { spacing } from '../config/theme';
|
|
5
|
+
import { MyazaButton } from '../components/MyazaButton';
|
|
6
|
+
import { ContactFooterNote } from './ContactFooterNote';
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// The contact step's footer: the one primary action (send, then verify), the
|
|
10
|
+
// optional skip, and the reassurance note. Split from ContactVerificationStep
|
|
11
|
+
// (200-line rule); the step owns every decision and this only renders them.
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
export function ContactActions({
|
|
15
|
+
hasChallenge,
|
|
16
|
+
busy,
|
|
17
|
+
disabled,
|
|
18
|
+
required,
|
|
19
|
+
isEmail,
|
|
20
|
+
onPrimary,
|
|
21
|
+
onSkip,
|
|
22
|
+
}: {
|
|
23
|
+
/** A code is out: the button verifies instead of sending. */
|
|
24
|
+
hasChallenge: boolean;
|
|
25
|
+
busy: boolean;
|
|
26
|
+
disabled: boolean;
|
|
27
|
+
/** `required: false` adds the skip; the server enforces the rest. */
|
|
28
|
+
required: boolean;
|
|
29
|
+
isEmail: boolean;
|
|
30
|
+
onPrimary: () => void;
|
|
31
|
+
onSkip: () => void;
|
|
32
|
+
}): React.ReactElement {
|
|
33
|
+
return (
|
|
34
|
+
<>
|
|
35
|
+
<View style={{ height: spacing.md }} />
|
|
36
|
+
<MyazaButton
|
|
37
|
+
label={hasChallenge ? 'Verify code' : 'Send code'}
|
|
38
|
+
loading={busy}
|
|
39
|
+
disabled={disabled}
|
|
40
|
+
onPress={onPrimary}
|
|
41
|
+
/>
|
|
42
|
+
|
|
43
|
+
{!required ? (
|
|
44
|
+
<>
|
|
45
|
+
<View style={{ height: spacing.sm }} />
|
|
46
|
+
<MyazaButton label="Skip for now" variant="ghost" disabled={busy} onPress={onSkip} />
|
|
47
|
+
</>
|
|
48
|
+
) : null}
|
|
49
|
+
|
|
50
|
+
<View style={{ height: spacing.md }} />
|
|
51
|
+
<ContactFooterNote isEmail={isEmail} />
|
|
52
|
+
</>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -18,6 +18,7 @@ export function ContactDestinationField({
|
|
|
18
18
|
onEmailChange,
|
|
19
19
|
onPhoneChange,
|
|
20
20
|
defaultCountry,
|
|
21
|
+
geoCountry,
|
|
21
22
|
error,
|
|
22
23
|
disabled,
|
|
23
24
|
}: {
|
|
@@ -26,6 +27,8 @@ export function ContactDestinationField({
|
|
|
26
27
|
onEmailChange: (value: string) => void;
|
|
27
28
|
onPhoneChange: (value: { e164: string; isValid: boolean }) => void;
|
|
28
29
|
defaultCountry?: string;
|
|
30
|
+
/** The visitor's IP country, pinned to the top of the dial-code picker. */
|
|
31
|
+
geoCountry?: string | null;
|
|
29
32
|
error: string | null;
|
|
30
33
|
disabled?: boolean;
|
|
31
34
|
}): React.ReactElement {
|
|
@@ -52,6 +55,7 @@ export function ContactDestinationField({
|
|
|
52
55
|
</MyazaText>
|
|
53
56
|
<PhoneNumberInput
|
|
54
57
|
defaultCountry={defaultCountry}
|
|
58
|
+
geoCountry={geoCountry}
|
|
55
59
|
disabled={disabled}
|
|
56
60
|
autoFocus
|
|
57
61
|
onChange={onPhoneChange}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { spacing } from '../config/theme';
|
|
5
|
+
import { MyazaAlert } from '../components/MyazaAlert';
|
|
6
|
+
import { ContactDestinationField } from './ContactDestinationField';
|
|
7
|
+
import { ContactChannelChoice } from './ContactChannelChoice';
|
|
8
|
+
import type { PhoneOtpChannel } from '../config/contact';
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// The first half of the contact step: where to send the code, and (when the
|
|
12
|
+
// workflow offers a choice) how. Composed as one widget so the step has a
|
|
13
|
+
// single child per phase. Split from ContactVerificationStep (200-line rule);
|
|
14
|
+
// mirrors Flutter's ContactEntryPanel.
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
export function ContactEntryPanel({
|
|
18
|
+
isEmail,
|
|
19
|
+
recovery,
|
|
20
|
+
email,
|
|
21
|
+
onEmailChange,
|
|
22
|
+
onPhoneChange,
|
|
23
|
+
defaultCountry,
|
|
24
|
+
geoCountry,
|
|
25
|
+
error,
|
|
26
|
+
disabled,
|
|
27
|
+
offeredChannels,
|
|
28
|
+
via,
|
|
29
|
+
onPickChannel,
|
|
30
|
+
}: {
|
|
31
|
+
isEmail: boolean;
|
|
32
|
+
/** Entered via submit recovery: the server refused this channel's proof. */
|
|
33
|
+
recovery: boolean;
|
|
34
|
+
email: string;
|
|
35
|
+
onEmailChange: (value: string) => void;
|
|
36
|
+
onPhoneChange: (value: { e164: string; isValid: boolean }) => void;
|
|
37
|
+
defaultCountry?: string;
|
|
38
|
+
geoCountry?: string | null;
|
|
39
|
+
error: string | null;
|
|
40
|
+
disabled: boolean;
|
|
41
|
+
offeredChannels: PhoneOtpChannel[];
|
|
42
|
+
via: PhoneOtpChannel;
|
|
43
|
+
onPickChannel: (channel: PhoneOtpChannel) => void;
|
|
44
|
+
}): React.ReactElement {
|
|
45
|
+
return (
|
|
46
|
+
<>
|
|
47
|
+
{recovery && (
|
|
48
|
+
<>
|
|
49
|
+
<MyazaAlert
|
|
50
|
+
variant="warning"
|
|
51
|
+
title="Please verify again"
|
|
52
|
+
message={`Your earlier confirmation has expired, so please verify ${isEmail ? 'your email' : 'your number'} once more. Everything else is saved, and we will submit again straight after.`}
|
|
53
|
+
/>
|
|
54
|
+
<View style={{ height: spacing.md }} />
|
|
55
|
+
</>
|
|
56
|
+
)}
|
|
57
|
+
<ContactDestinationField
|
|
58
|
+
isEmail={isEmail}
|
|
59
|
+
email={email}
|
|
60
|
+
onEmailChange={onEmailChange}
|
|
61
|
+
onPhoneChange={onPhoneChange}
|
|
62
|
+
defaultCountry={defaultCountry}
|
|
63
|
+
geoCountry={geoCountry}
|
|
64
|
+
error={error}
|
|
65
|
+
disabled={disabled}
|
|
66
|
+
/>
|
|
67
|
+
<ContactChannelChoice
|
|
68
|
+
offered={offeredChannels}
|
|
69
|
+
picked={via}
|
|
70
|
+
disabled={disabled}
|
|
71
|
+
onPick={onPickChannel}
|
|
72
|
+
/>
|
|
73
|
+
</>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -3,13 +3,10 @@ import { View } from 'react-native';
|
|
|
3
3
|
|
|
4
4
|
import { spacing } from '../config/theme';
|
|
5
5
|
import { useEffectiveCountry, useKyc, useKycConfig, useKycStore } from '../components/runtime';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { ContactDestinationField } from './ContactDestinationField';
|
|
6
|
+
import { ContactActions } from './ContactActions';
|
|
7
|
+
import { ContactEntryPanel } from './ContactEntryPanel';
|
|
9
8
|
import { ContactVerifiedPanel } from './ContactVerifiedPanel';
|
|
10
9
|
import { ContactCodeStep } from './ContactCodeStep';
|
|
11
|
-
import { ContactChannelChoice } from './ContactChannelChoice';
|
|
12
|
-
import { ContactFooterNote } from './ContactFooterNote';
|
|
13
10
|
import {
|
|
14
11
|
contactCodeLength,
|
|
15
12
|
contactIsRequired,
|
|
@@ -57,10 +54,9 @@ export function ContactVerificationStep({
|
|
|
57
54
|
const [destination, setDestination] = useState(contact.emailAddress ?? '');
|
|
58
55
|
// The phone field emits E.164 + validity from libphonenumber, so the step no
|
|
59
56
|
// longer guesses either from raw text.
|
|
60
|
-
const [phone, setPhone] = useState<{ e164: string; isValid: boolean }>(
|
|
61
|
-
e164: contact.phoneNumber ?? '',
|
|
62
|
-
|
|
63
|
-
});
|
|
57
|
+
const [phone, setPhone] = useState<{ e164: string; isValid: boolean }>(
|
|
58
|
+
{ e164: contact.phoneNumber ?? '', isValid: false },
|
|
59
|
+
);
|
|
64
60
|
const [challengeId, setChallengeId] = useState<string | null>(null);
|
|
65
61
|
const [expiresAt, setExpiresAt] = useState<string | null>(null);
|
|
66
62
|
const [code, setCode] = useState('');
|
|
@@ -174,57 +170,31 @@ export function ContactVerificationStep({
|
|
|
174
170
|
onResend={(switchTo) => void send(switchTo)}
|
|
175
171
|
/>
|
|
176
172
|
) : (
|
|
177
|
-
|
|
178
|
-
{
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
onEmailChange={setDestination}
|
|
192
|
-
onPhoneChange={setPhone}
|
|
193
|
-
defaultCountry={
|
|
194
|
-
config.phoneVerification?.defaultCountry ??
|
|
195
|
-
country ??
|
|
196
|
-
serverConfig.geoCountry ??
|
|
197
|
-
undefined
|
|
198
|
-
}
|
|
199
|
-
error={error}
|
|
200
|
-
disabled={busy}
|
|
201
|
-
/>
|
|
202
|
-
<ContactChannelChoice
|
|
203
|
-
offered={offeredChannels}
|
|
204
|
-
picked={via}
|
|
205
|
-
disabled={busy}
|
|
206
|
-
onPick={setVia}
|
|
207
|
-
/>
|
|
208
|
-
</>
|
|
173
|
+
<ContactEntryPanel
|
|
174
|
+
isEmail={isEmail}
|
|
175
|
+
recovery={recovery}
|
|
176
|
+
email={destination}
|
|
177
|
+
onEmailChange={setDestination}
|
|
178
|
+
onPhoneChange={setPhone}
|
|
179
|
+
defaultCountry={config.phoneVerification?.defaultCountry ?? country ?? serverConfig.geoCountry ?? undefined}
|
|
180
|
+
geoCountry={serverConfig.geoCountry}
|
|
181
|
+
error={error}
|
|
182
|
+
disabled={busy}
|
|
183
|
+
offeredChannels={offeredChannels}
|
|
184
|
+
via={via}
|
|
185
|
+
onPickChannel={setVia}
|
|
186
|
+
/>
|
|
209
187
|
)}
|
|
210
188
|
|
|
211
|
-
<
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
loading={busy}
|
|
189
|
+
<ContactActions
|
|
190
|
+
hasChallenge={challengeId != null}
|
|
191
|
+
busy={busy}
|
|
215
192
|
disabled={challengeId ? code.length !== codeLength : !canSend}
|
|
216
|
-
|
|
193
|
+
required={required}
|
|
194
|
+
isEmail={isEmail}
|
|
195
|
+
onPrimary={() => (challengeId ? void check(code) : void send())}
|
|
196
|
+
onSkip={advance}
|
|
217
197
|
/>
|
|
218
|
-
|
|
219
|
-
{!required ? (
|
|
220
|
-
<>
|
|
221
|
-
<View style={{ height: spacing.sm }} />
|
|
222
|
-
<MyazaButton label="Skip for now" variant="ghost" disabled={busy} onPress={advance} />
|
|
223
|
-
</>
|
|
224
|
-
) : null}
|
|
225
|
-
|
|
226
|
-
<View style={{ height: spacing.md }} />
|
|
227
|
-
<ContactFooterNote isEmail={isEmail} />
|
|
228
198
|
</View>
|
|
229
199
|
);
|
|
230
200
|
}
|