@myazahq/kyc-sdk-react-native 2.5.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +151 -5
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +6 -4
- package/app.plugin.js +89 -8
- package/ios/HybridMyazaFaceDetector.swift +8 -3
- package/package.json +14 -2
- package/src/MyazaBiometricAuth.tsx +119 -0
- 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/FramedMapPicker.tsx +122 -0
- package/src/components/GeoBadge.tsx +34 -0
- package/src/components/Icon.tsx +2 -185
- 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/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/StepView.tsx +17 -0
- package/src/components/StickyActions.tsx +56 -0
- package/src/components/dialCodeRows.ts +64 -0
- package/src/components/icon-map.ts +176 -0
- package/src/components/stepHeaderMeta.tsx +42 -4
- 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/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 +11 -5
- 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/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/poa-country-gate.ts +27 -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/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/useLiveness.ts +6 -1
- 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/LivenessAvatar.tsx +15 -3
- package/src/screens/LivenessStep.tsx +154 -52
- 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/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/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-biometric.ts +37 -0
- package/src/services/api-types-biometric.ts +41 -0
- package/src/services/api-types.ts +93 -1
- package/src/services/api-verify-types.ts +37 -0
- package/src/services/api.ts +133 -3
- package/src/services/deviceMetadata.ts +1 -1
- package/src/services/location.ts +234 -0
- package/src/services/uploadErrors.ts +30 -0
- package/src/services/workflowGate.ts +7 -0
- package/src/store/address.ts +97 -0
- package/src/store/derive.ts +29 -4
- package/src/store/kycStore.ts +112 -3
- 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 +48 -1
- package/src/types/verification.ts +12 -0
- package/src/types/workflow.ts +97 -1
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Image, Pressable, View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { radius } from '../../config/theme';
|
|
5
|
+
import { useTheme } from '../../components/runtime';
|
|
6
|
+
import { MapPinPicker } from '../../components/MapPinPicker';
|
|
7
|
+
import { FramedMapPicker } from '../../components/FramedMapPicker';
|
|
8
|
+
import { MapPinMarker } from '../../components/MapPinMarker';
|
|
9
|
+
import { reviewMapSurface } from '../../lib/review-map-surface';
|
|
10
|
+
import type { LatLng } from '../../lib/map-tiles';
|
|
11
|
+
import { AddressMapStub } from './AddressMapStub';
|
|
12
|
+
|
|
13
|
+
// The review card's map (200-line split from AddressReviewStep). The map as a
|
|
14
|
+
// PICTURE: a confirmation screen wants a photograph of the place, not a second
|
|
15
|
+
// instrument. Which surface draws is lib/review-map-surface.ts, the order the
|
|
16
|
+
// web and Flutter cards keep.
|
|
17
|
+
|
|
18
|
+
// Web's review map is h-48 (192px) at mobile widths; keep the mirror exact.
|
|
19
|
+
export const REVIEW_MAP_HEIGHT = 192;
|
|
20
|
+
|
|
21
|
+
export function ReviewMapPicture({
|
|
22
|
+
pin,
|
|
23
|
+
vendorsStubbed,
|
|
24
|
+
mapsFrameUrl,
|
|
25
|
+
staticMap,
|
|
26
|
+
staticMapFailed,
|
|
27
|
+
onEdit,
|
|
28
|
+
onPictureFailed,
|
|
29
|
+
}: {
|
|
30
|
+
pin: LatLng;
|
|
31
|
+
vendorsStubbed: boolean;
|
|
32
|
+
mapsFrameUrl: string | null;
|
|
33
|
+
/** The picture, once fetched with the SDK's bearer (lib/authed-image). */
|
|
34
|
+
staticMap: { uri: string } | null;
|
|
35
|
+
staticMapFailed: boolean;
|
|
36
|
+
onEdit: () => void;
|
|
37
|
+
onPictureFailed: () => void;
|
|
38
|
+
}): React.ReactElement {
|
|
39
|
+
const { colors } = useTheme();
|
|
40
|
+
const surface = reviewMapSurface({
|
|
41
|
+
vendorsStubbed,
|
|
42
|
+
hasStaticMap: staticMap != null,
|
|
43
|
+
staticMapFailed,
|
|
44
|
+
hasFrame: mapsFrameUrl != null,
|
|
45
|
+
});
|
|
46
|
+
return (
|
|
47
|
+
<Pressable
|
|
48
|
+
onPress={onEdit}
|
|
49
|
+
accessibilityRole="button"
|
|
50
|
+
accessibilityLabel="Edit the pinned location"
|
|
51
|
+
// The picture's own clip: the card above it no longer has one, so the
|
|
52
|
+
// entrance can hang over its bottom edge.
|
|
53
|
+
style={{ borderTopLeftRadius: radius.md - 1, borderTopRightRadius: radius.md - 1, overflow: 'hidden' }}
|
|
54
|
+
>
|
|
55
|
+
{surface === 'stub' ? (
|
|
56
|
+
<AddressMapStub hasPin onLand={() => undefined} defaultCenter={pin} height={REVIEW_MAP_HEIGHT} />
|
|
57
|
+
) : surface === 'picture' ? (
|
|
58
|
+
<View>
|
|
59
|
+
<Image
|
|
60
|
+
source={staticMap!}
|
|
61
|
+
accessibilityLabel="Map of the pinned location"
|
|
62
|
+
onError={onPictureFailed}
|
|
63
|
+
style={{ width: '100%', height: REVIEW_MAP_HEIGHT, backgroundColor: colors.backgroundSecondary }}
|
|
64
|
+
resizeMode="cover"
|
|
65
|
+
/>
|
|
66
|
+
{/* The SDK's own pin, centred on the picture, so the flow shows one
|
|
67
|
+
pin throughout rather than a vendor marker on the last screen. */}
|
|
68
|
+
<View
|
|
69
|
+
pointerEvents="none"
|
|
70
|
+
style={{ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, alignItems: 'center', justifyContent: 'center' }}
|
|
71
|
+
>
|
|
72
|
+
<MapPinMarker lifted={false} color={colors.primary} />
|
|
73
|
+
</View>
|
|
74
|
+
</View>
|
|
75
|
+
) : surface === 'pending' ? (
|
|
76
|
+
<View style={{ height: REVIEW_MAP_HEIGHT, backgroundColor: colors.backgroundSecondary }} />
|
|
77
|
+
) : surface === 'framed' ? (
|
|
78
|
+
// The SAME framed map the pin step draws, so the applicant confirms on
|
|
79
|
+
// the imagery they placed the pin over. Touches are swallowed: the
|
|
80
|
+
// pane above it is the way back to editing.
|
|
81
|
+
<View pointerEvents="none">
|
|
82
|
+
<FramedMapPicker
|
|
83
|
+
frameUrl={mapsFrameUrl!}
|
|
84
|
+
value={pin}
|
|
85
|
+
onChange={() => undefined}
|
|
86
|
+
defaultCenter={pin}
|
|
87
|
+
defaultZoom={16}
|
|
88
|
+
height={REVIEW_MAP_HEIGHT}
|
|
89
|
+
cornerRadius={0}
|
|
90
|
+
/>
|
|
91
|
+
</View>
|
|
92
|
+
) : (
|
|
93
|
+
<MapPinPicker
|
|
94
|
+
value={pin}
|
|
95
|
+
onChange={() => undefined}
|
|
96
|
+
defaultCenter={pin}
|
|
97
|
+
defaultZoom={16}
|
|
98
|
+
pinZoom={16}
|
|
99
|
+
height={REVIEW_MAP_HEIGHT}
|
|
100
|
+
interactive={false}
|
|
101
|
+
cornerRadius={0}
|
|
102
|
+
/>
|
|
103
|
+
)}
|
|
104
|
+
</Pressable>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Pressable, View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { radius, spacing } from '../../config/theme';
|
|
5
|
+
import { useTheme } from '../../components/runtime';
|
|
6
|
+
import { Icon } from '../../components/Icon';
|
|
7
|
+
import { MyazaText } from '../../components/Typography';
|
|
8
|
+
import type { AddressSearchHit, PlaceSuggestion } from '../../services/api';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The candidate list for either backend.
|
|
12
|
+
*
|
|
13
|
+
* A FAILED call renders the same empty state as no matches: there is no
|
|
14
|
+
* separate error state here, because either way the answer is the same, and
|
|
15
|
+
* the two escapes below the list are how the applicant takes it.
|
|
16
|
+
*/
|
|
17
|
+
export function SearchResults({
|
|
18
|
+
autocomplete,
|
|
19
|
+
suggestions,
|
|
20
|
+
hits,
|
|
21
|
+
busy,
|
|
22
|
+
onPickSuggestion,
|
|
23
|
+
onPickHit,
|
|
24
|
+
}: {
|
|
25
|
+
autocomplete: boolean;
|
|
26
|
+
/** Places suggestions; null before anything has been asked. */
|
|
27
|
+
suggestions: PlaceSuggestion[] | null;
|
|
28
|
+
/** Basic-search hits; null before a submit. */
|
|
29
|
+
hits: AddressSearchHit[] | null;
|
|
30
|
+
busy: boolean;
|
|
31
|
+
onPickSuggestion: (s: PlaceSuggestion) => void;
|
|
32
|
+
onPickHit: (hit: AddressSearchHit) => void;
|
|
33
|
+
}): React.ReactElement | null {
|
|
34
|
+
const { colors } = useTheme();
|
|
35
|
+
const rows = autocomplete ? suggestions : hits;
|
|
36
|
+
if (rows === null) return null;
|
|
37
|
+
|
|
38
|
+
const frame = {
|
|
39
|
+
marginTop: spacing.sm,
|
|
40
|
+
borderWidth: 1,
|
|
41
|
+
borderColor: colors.border,
|
|
42
|
+
borderRadius: radius.md,
|
|
43
|
+
overflow: 'hidden' as const,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
if (rows.length === 0) {
|
|
47
|
+
return (
|
|
48
|
+
<View style={frame}>
|
|
49
|
+
<MyazaText
|
|
50
|
+
variant="bodyMedium"
|
|
51
|
+
color={colors.textSecondary}
|
|
52
|
+
style={{ paddingHorizontal: spacing.md, paddingVertical: spacing.sm + 2 }}
|
|
53
|
+
>
|
|
54
|
+
{autocomplete
|
|
55
|
+
? 'No matches. Use your location or place the pin by hand.'
|
|
56
|
+
: 'No matches. Drag the map to place the pin instead.'}
|
|
57
|
+
</MyazaText>
|
|
58
|
+
</View>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<View style={frame}>
|
|
64
|
+
{rows.map((row, i) => {
|
|
65
|
+
const isSuggestion = 'placeId' in row;
|
|
66
|
+
const primary = isSuggestion ? row.mainText : row.label;
|
|
67
|
+
const secondary = isSuggestion ? row.secondaryText : null;
|
|
68
|
+
return (
|
|
69
|
+
<Pressable
|
|
70
|
+
key={isSuggestion ? row.placeId : `${row.lat},${row.lng},${i}`}
|
|
71
|
+
disabled={busy}
|
|
72
|
+
onPress={() => (isSuggestion ? onPickSuggestion(row) : onPickHit(row))}
|
|
73
|
+
accessibilityRole="button"
|
|
74
|
+
accessibilityLabel={primary}
|
|
75
|
+
style={({ pressed }) => ({
|
|
76
|
+
flexDirection: 'row',
|
|
77
|
+
alignItems: 'flex-start',
|
|
78
|
+
gap: spacing.sm,
|
|
79
|
+
paddingHorizontal: spacing.md,
|
|
80
|
+
paddingVertical: spacing.sm + 2,
|
|
81
|
+
opacity: busy ? 0.6 : 1,
|
|
82
|
+
backgroundColor: pressed ? colors.backgroundSecondary : 'transparent',
|
|
83
|
+
borderTopWidth: i === 0 ? 0 : 1,
|
|
84
|
+
borderTopColor: colors.border,
|
|
85
|
+
})}
|
|
86
|
+
>
|
|
87
|
+
<View style={{ paddingTop: 2 }}>
|
|
88
|
+
<Icon name="map-pin" size={15} color={colors.primary} />
|
|
89
|
+
</View>
|
|
90
|
+
<View style={{ flex: 1, minWidth: 0 }}>
|
|
91
|
+
<MyazaText variant="bodyMedium" style={{ fontWeight: '500' }}>
|
|
92
|
+
{primary}
|
|
93
|
+
</MyazaText>
|
|
94
|
+
{secondary ? (
|
|
95
|
+
<MyazaText variant="bodySmall" color={colors.textSecondary}>
|
|
96
|
+
{secondary}
|
|
97
|
+
</MyazaText>
|
|
98
|
+
) : null}
|
|
99
|
+
</View>
|
|
100
|
+
</Pressable>
|
|
101
|
+
);
|
|
102
|
+
})}
|
|
103
|
+
</View>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { Pressable, View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { spacing } from '../../config/theme';
|
|
5
|
+
import { useKycStore, useTheme } from '../../components/runtime';
|
|
6
|
+
import { MyazaText } from '../../components/Typography';
|
|
7
|
+
import { uuid } from '../../utils/uuid';
|
|
8
|
+
import { AUTOCOMPLETE_DEBOUNCE_MS, SEARCH_MIN_QUERY_LENGTH } from '../../lib/address-flow';
|
|
9
|
+
import { AddressSearchField } from './AddressSearchField';
|
|
10
|
+
import { CurrentLocationRow } from './CurrentLocationRow';
|
|
11
|
+
import { SearchResults } from './SearchResults';
|
|
12
|
+
import type { AddressSearchHit, PlaceSuggestion } from '../../services/api';
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Screen 1: find the address the way a person holds it, as words.
|
|
16
|
+
//
|
|
17
|
+
// Places autocomplete when the platform has it (as-you-type, debounced, ONE
|
|
18
|
+
// session token per typing session — the billing unit), the explicit-submit
|
|
19
|
+
// basic search otherwise, and two always-present escapes: current location and
|
|
20
|
+
// "place a pin instead". Every path lands on the pin screen.
|
|
21
|
+
//
|
|
22
|
+
// A failed call renders the SAME empty state as no matches: there is no
|
|
23
|
+
// separate error state on this list, because either way the answer is to use
|
|
24
|
+
// one of the escapes.
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
/** What either backend hands back once resolved to coordinates. */
|
|
28
|
+
export interface ResolvedSearch {
|
|
29
|
+
lat: number;
|
|
30
|
+
lng: number;
|
|
31
|
+
houseNumber: string | null;
|
|
32
|
+
road: string | null;
|
|
33
|
+
formatted?: string | null;
|
|
34
|
+
area?: string | null;
|
|
35
|
+
city?: string | null;
|
|
36
|
+
state?: string | null;
|
|
37
|
+
postcode?: string | null;
|
|
38
|
+
/** ISO-2 of the picked address's own country (the declaration follows it). */
|
|
39
|
+
country?: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function SearchScreen({
|
|
43
|
+
country,
|
|
44
|
+
autocomplete,
|
|
45
|
+
near,
|
|
46
|
+
locationHint,
|
|
47
|
+
locating,
|
|
48
|
+
onResolved,
|
|
49
|
+
onUseMyLocation,
|
|
50
|
+
onPinInstead,
|
|
51
|
+
}: {
|
|
52
|
+
country?: string | null;
|
|
53
|
+
/** Which backend the server offers. */
|
|
54
|
+
autocomplete: boolean;
|
|
55
|
+
/** The device fix: a ranking bias so nearby streets come first. */
|
|
56
|
+
near?: { lat: number; lng: number } | null;
|
|
57
|
+
/** The device's resolved current address, shown ON the row. */
|
|
58
|
+
locationHint: string | null;
|
|
59
|
+
locating: boolean;
|
|
60
|
+
onResolved: (hit: ResolvedSearch) => void;
|
|
61
|
+
onUseMyLocation: () => void;
|
|
62
|
+
onPinInstead: () => void;
|
|
63
|
+
}): React.ReactElement {
|
|
64
|
+
const store = useKycStore();
|
|
65
|
+
const { colors } = useTheme();
|
|
66
|
+
const [query, setQuery] = useState('');
|
|
67
|
+
const [busy, setBusy] = useState(false);
|
|
68
|
+
const [suggestions, setSuggestions] = useState<PlaceSuggestion[] | null>(null);
|
|
69
|
+
const [hits, setHits] = useState<AddressSearchHit[] | null>(null);
|
|
70
|
+
const session = useRef(uuid());
|
|
71
|
+
const debounce = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (!autocomplete) return undefined;
|
|
75
|
+
if (debounce.current) clearTimeout(debounce.current);
|
|
76
|
+
const q = query.trim();
|
|
77
|
+
if (q.length < SEARCH_MIN_QUERY_LENGTH) {
|
|
78
|
+
setSuggestions(null);
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
debounce.current = setTimeout(() => {
|
|
82
|
+
void store
|
|
83
|
+
.getState()
|
|
84
|
+
.api.addressAutocomplete(q, session.current, country ?? undefined, near ?? null)
|
|
85
|
+
.then((res) => setSuggestions(res.suggestions))
|
|
86
|
+
.catch(() => setSuggestions([]));
|
|
87
|
+
}, AUTOCOMPLETE_DEBOUNCE_MS);
|
|
88
|
+
return () => {
|
|
89
|
+
if (debounce.current) clearTimeout(debounce.current);
|
|
90
|
+
};
|
|
91
|
+
// The fix is read by value: a fresh object with the same coordinates must
|
|
92
|
+
// not re-run the search.
|
|
93
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
94
|
+
}, [query, autocomplete, country, store, near?.lat, near?.lng]);
|
|
95
|
+
|
|
96
|
+
const pickSuggestion = useCallback(
|
|
97
|
+
async (s: PlaceSuggestion) => {
|
|
98
|
+
setBusy(true);
|
|
99
|
+
try {
|
|
100
|
+
const { place } = await store.getState().api.addressPlace(s.placeId, session.current);
|
|
101
|
+
// A details call CLOSES the Places session, so the next keystroke must
|
|
102
|
+
// start a new one or the billing unit silently spans two searches.
|
|
103
|
+
session.current = uuid();
|
|
104
|
+
onResolved(place);
|
|
105
|
+
} catch {
|
|
106
|
+
setSuggestions([]);
|
|
107
|
+
} finally {
|
|
108
|
+
setBusy(false);
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
[onResolved, store],
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
/** The basic backend forbids autocomplete, so the query only goes out on an
|
|
115
|
+
* explicit submit — the budget belongs to the query the person meant. */
|
|
116
|
+
const runBasicSearch = useCallback(async () => {
|
|
117
|
+
const q = query.trim();
|
|
118
|
+
if (q.length < SEARCH_MIN_QUERY_LENGTH || busy) return;
|
|
119
|
+
setBusy(true);
|
|
120
|
+
try {
|
|
121
|
+
const res = await store.getState().api.addressSearch(q, country ?? undefined);
|
|
122
|
+
setHits(res.results);
|
|
123
|
+
} catch {
|
|
124
|
+
setHits([]);
|
|
125
|
+
} finally {
|
|
126
|
+
setBusy(false);
|
|
127
|
+
}
|
|
128
|
+
}, [busy, country, query, store]);
|
|
129
|
+
|
|
130
|
+
const canSubmit = query.trim().length >= SEARCH_MIN_QUERY_LENGTH && !busy;
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<View>
|
|
134
|
+
<AddressSearchField
|
|
135
|
+
value={query}
|
|
136
|
+
onChangeText={setQuery}
|
|
137
|
+
busy={busy}
|
|
138
|
+
showSubmit={!autocomplete}
|
|
139
|
+
canSubmit={canSubmit}
|
|
140
|
+
autoFocus={autocomplete}
|
|
141
|
+
onSubmit={() => void runBasicSearch()}
|
|
142
|
+
/>
|
|
143
|
+
|
|
144
|
+
<SearchResults
|
|
145
|
+
autocomplete={autocomplete}
|
|
146
|
+
suggestions={suggestions}
|
|
147
|
+
hits={hits}
|
|
148
|
+
busy={busy}
|
|
149
|
+
onPickSuggestion={(s) => void pickSuggestion(s)}
|
|
150
|
+
onPickHit={(hit) => {
|
|
151
|
+
setQuery('');
|
|
152
|
+
setHits(null);
|
|
153
|
+
onResolved({ ...hit, formatted: hit.label });
|
|
154
|
+
}}
|
|
155
|
+
/>
|
|
156
|
+
|
|
157
|
+
<View style={{ height: spacing.md }} />
|
|
158
|
+
<CurrentLocationRow hint={locationHint} locating={locating} onPress={onUseMyLocation} />
|
|
159
|
+
|
|
160
|
+
<View style={{ height: spacing.md }} />
|
|
161
|
+
<Pressable
|
|
162
|
+
onPress={onPinInstead}
|
|
163
|
+
accessibilityRole="button"
|
|
164
|
+
style={{ alignItems: 'center', paddingVertical: spacing.sm }}
|
|
165
|
+
>
|
|
166
|
+
<MyazaText
|
|
167
|
+
variant="bodyMedium"
|
|
168
|
+
color={colors.textSecondary}
|
|
169
|
+
style={{ textDecorationLine: 'underline' }}
|
|
170
|
+
>
|
|
171
|
+
Place a pin on the map instead
|
|
172
|
+
</MyazaText>
|
|
173
|
+
</Pressable>
|
|
174
|
+
</View>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Pressable } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { spacing } from '../../config/theme';
|
|
5
|
+
import { useTheme } from '../../components/runtime';
|
|
6
|
+
import { MyazaText } from '../../components/Typography';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The address flow's escape, on the search / pin / review steps.
|
|
10
|
+
*
|
|
11
|
+
* It leaves the WHOLE flow, not just the step: a person who does not want to
|
|
12
|
+
* give an address should not be walked through the remaining three screens to
|
|
13
|
+
* decline three more times. The entrance step has no skip link of its own — it
|
|
14
|
+
* carries its own escapes instead.
|
|
15
|
+
*
|
|
16
|
+
* Hidden entirely when the workflow requires a pin.
|
|
17
|
+
*/
|
|
18
|
+
export function SkipForNow({
|
|
19
|
+
requirePin,
|
|
20
|
+
onPress,
|
|
21
|
+
}: {
|
|
22
|
+
requirePin: boolean | undefined;
|
|
23
|
+
onPress: () => void;
|
|
24
|
+
}): React.ReactElement | null {
|
|
25
|
+
const { colors } = useTheme();
|
|
26
|
+
if (requirePin === true) return null;
|
|
27
|
+
return (
|
|
28
|
+
<Pressable
|
|
29
|
+
onPress={onPress}
|
|
30
|
+
accessibilityRole="button"
|
|
31
|
+
accessibilityLabel="Skip for now"
|
|
32
|
+
style={{ alignItems: 'center', marginTop: spacing.md, paddingVertical: spacing.sm }}
|
|
33
|
+
>
|
|
34
|
+
<MyazaText
|
|
35
|
+
variant="bodyMedium"
|
|
36
|
+
color={colors.textSecondary}
|
|
37
|
+
style={{ textDecorationLine: 'underline' }}
|
|
38
|
+
>
|
|
39
|
+
Skip for now
|
|
40
|
+
</MyazaText>
|
|
41
|
+
</Pressable>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { MyazaText } from '../../components/Typography';
|
|
5
|
+
|
|
6
|
+
// The framing chrome over the Street View panorama: a dimmed surround, a
|
|
7
|
+
// white rounded frame and the caption above it. A mirror of the web SDK's
|
|
8
|
+
// framer overlay (identical geometry, so hosted, embedded and native
|
|
9
|
+
// applicants meet the same instrument) and the Flutter street_view_chrome.
|
|
10
|
+
// RN has no box-shadow spread, so the dim mask is four views around the
|
|
11
|
+
// frame. pointerEvents="none" throughout: the drag reaches the WebView.
|
|
12
|
+
|
|
13
|
+
export interface FrameRect {
|
|
14
|
+
left: number;
|
|
15
|
+
top: number;
|
|
16
|
+
width: number;
|
|
17
|
+
height: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The frame's rectangle inside a viewport: 58% wide (max 320), 56% tall
|
|
21
|
+
* (max 300), centred horizontally, its centre 44% down. */
|
|
22
|
+
export function streetViewFrameRect(viewportWidth: number, viewportHeight: number): FrameRect {
|
|
23
|
+
const width = Math.min(viewportWidth * 0.58, 320);
|
|
24
|
+
const height = Math.min(viewportHeight * 0.56, 300);
|
|
25
|
+
return {
|
|
26
|
+
left: (viewportWidth - width) / 2,
|
|
27
|
+
top: viewportHeight * 0.44 - height / 2,
|
|
28
|
+
width,
|
|
29
|
+
height,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const DIM = 'rgba(0, 0, 0, 0.45)';
|
|
34
|
+
|
|
35
|
+
export function StreetViewChrome({
|
|
36
|
+
viewportWidth,
|
|
37
|
+
viewportHeight,
|
|
38
|
+
}: {
|
|
39
|
+
viewportWidth: number;
|
|
40
|
+
viewportHeight: number;
|
|
41
|
+
}): React.ReactElement | null {
|
|
42
|
+
if (viewportWidth <= 0 || viewportHeight <= 0) return null;
|
|
43
|
+
const f = streetViewFrameRect(viewportWidth, viewportHeight);
|
|
44
|
+
const right = viewportWidth - f.left - f.width;
|
|
45
|
+
const bottom = viewportHeight - f.top - f.height;
|
|
46
|
+
return (
|
|
47
|
+
<View pointerEvents="none" style={{ position: 'absolute', top: 0, right: 0, bottom: 0, left: 0 }}>
|
|
48
|
+
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, height: f.top, backgroundColor: DIM }} />
|
|
49
|
+
<View style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: bottom, backgroundColor: DIM }} />
|
|
50
|
+
<View style={{ position: 'absolute', top: f.top, left: 0, width: f.left, height: f.height, backgroundColor: DIM }} />
|
|
51
|
+
<View style={{ position: 'absolute', top: f.top, right: 0, width: right, height: f.height, backgroundColor: DIM }} />
|
|
52
|
+
<View
|
|
53
|
+
style={{
|
|
54
|
+
position: 'absolute',
|
|
55
|
+
top: f.top,
|
|
56
|
+
left: f.left,
|
|
57
|
+
width: f.width,
|
|
58
|
+
height: f.height,
|
|
59
|
+
borderRadius: 16,
|
|
60
|
+
borderWidth: 2,
|
|
61
|
+
borderColor: 'rgba(255, 255, 255, 0.95)',
|
|
62
|
+
}}
|
|
63
|
+
/>
|
|
64
|
+
<View
|
|
65
|
+
style={{
|
|
66
|
+
position: 'absolute',
|
|
67
|
+
top: f.top - 40,
|
|
68
|
+
left: 0,
|
|
69
|
+
right: 0,
|
|
70
|
+
alignItems: 'center',
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
<View style={{ borderRadius: 999, backgroundColor: 'rgba(0, 0, 0, 0.65)', paddingHorizontal: 12, paddingVertical: 6 }}>
|
|
74
|
+
<MyazaText variant="bodySmall" color="#ffffff" style={{ fontWeight: '600' }}>
|
|
75
|
+
Fit your entrance in the frame
|
|
76
|
+
</MyazaText>
|
|
77
|
+
</View>
|
|
78
|
+
</View>
|
|
79
|
+
</View>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { AddressState } from '../../store/state';
|
|
2
|
+
import type { DetailValues } from './DetailsSheetFields';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The details-sheet values of an address, in the sheet's own shape: the
|
|
6
|
+
* property fields as '' when absent, the area fields as undefined when never
|
|
7
|
+
* touched (so the map's prefill shows) and '' when deliberately cleared.
|
|
8
|
+
* Shared by the pin step (sheet + summary row) so the two never disagree.
|
|
9
|
+
*/
|
|
10
|
+
export function detailValuesOf(address: AddressState | null | undefined): DetailValues {
|
|
11
|
+
return {
|
|
12
|
+
propertyNumber: address?.propertyNumber ?? '',
|
|
13
|
+
street: address?.street,
|
|
14
|
+
unit: address?.unit,
|
|
15
|
+
propertyName: address?.propertyName ?? '',
|
|
16
|
+
directions: address?.directions ?? '',
|
|
17
|
+
neighbourhood: address?.neighbourhood,
|
|
18
|
+
city: address?.city,
|
|
19
|
+
state: address?.state,
|
|
20
|
+
postcode: address?.postcode,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { precisePositionOutcome } from '../../services/location';
|
|
2
|
+
import { addressVendorsStubbed, SAMPLE_ADDRESS_LINE } from '../../lib/address-flow';
|
|
3
|
+
import type { FixSource } from '../../lib/address-current-location';
|
|
4
|
+
import type { KycStore } from '../../store/state';
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// What the current-location cache needs from the flow: the GPS reader and the
|
|
8
|
+
// reverse geocoder. SANDBOX stubs BOTH (the web SDK's vendor-stub rule): a
|
|
9
|
+
// canned fix with no permission prompt, and the sample label with no geocoder
|
|
10
|
+
// call — the server's sandbox verdicts are canned anyway. Split from
|
|
11
|
+
// use-pin-actions.ts (200-line rule).
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
export function fixSourceFor(store: KycStore): FixSource {
|
|
15
|
+
const stubbed = (): boolean =>
|
|
16
|
+
addressVendorsStubbed({ environment: store.getState().serverConfig.environment });
|
|
17
|
+
return {
|
|
18
|
+
takeFix: () =>
|
|
19
|
+
stubbed()
|
|
20
|
+
? Promise.resolve({ fix: { lat: 6.4281, lng: 3.4219, accuracy: 15 } })
|
|
21
|
+
: precisePositionOutcome(),
|
|
22
|
+
addressReverse: (lat: number, lng: number) =>
|
|
23
|
+
stubbed()
|
|
24
|
+
? Promise.resolve({ line: SAMPLE_ADDRESS_LINE, parts: null })
|
|
25
|
+
: store.getState().api.addressReverse(lat, lng),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// The address flow's public surface, so the step router and the header meta
|
|
2
|
+
// import one path rather than five.
|
|
3
|
+
|
|
4
|
+
export { AddressSearchStep } from './AddressSearchStep';
|
|
5
|
+
export { AddressPinStep } from './AddressPinStep';
|
|
6
|
+
export { AddressEntranceStep } from './AddressEntranceStep';
|
|
7
|
+
export { AddressReviewStep } from './AddressReviewStep';
|
|
8
|
+
export { addressStepMeta } from './meta';
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { KYCStep } from '../../types/config';
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// The sheet header's title and description for each address step.
|
|
5
|
+
//
|
|
6
|
+
// The address screens do not draw their own headers — the sheet does, from
|
|
7
|
+
// here — so this is where their copy lives, beside the screens it describes.
|
|
8
|
+
// KYB says "premises" where the individual flow says "your building": the pin
|
|
9
|
+
// is the company's registered place of work, not somebody's home.
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
export function addressStepMeta(
|
|
13
|
+
step: KYCStep,
|
|
14
|
+
isBusiness: boolean,
|
|
15
|
+
/** The entrance step is framing street imagery, which describes THAT, not a camera. */
|
|
16
|
+
opts: { framing?: boolean } = {},
|
|
17
|
+
): { title: string; description: string } {
|
|
18
|
+
switch (step) {
|
|
19
|
+
case 'address-search':
|
|
20
|
+
return {
|
|
21
|
+
title: 'Find your address',
|
|
22
|
+
description: 'Search it, use your current location, or place a pin on the map.',
|
|
23
|
+
};
|
|
24
|
+
case 'address-entrance':
|
|
25
|
+
return {
|
|
26
|
+
title: 'Show the entrance',
|
|
27
|
+
description: opts.framing
|
|
28
|
+
? 'Frame your entrance in the street imagery. No camera needed.'
|
|
29
|
+
: 'A picture of the gate or front door makes the address findable.',
|
|
30
|
+
};
|
|
31
|
+
case 'address-review':
|
|
32
|
+
return {
|
|
33
|
+
title: isBusiness ? 'Confirm the premises' : 'Confirm your address',
|
|
34
|
+
description: 'Check everything is right before you continue.',
|
|
35
|
+
};
|
|
36
|
+
case 'address-collection':
|
|
37
|
+
default:
|
|
38
|
+
return {
|
|
39
|
+
title: isBusiness ? 'Is the pin on the premises?' : 'Is the pin on your building?',
|
|
40
|
+
description:
|
|
41
|
+
'Drag the map until the pin sits exactly on it. You can add details for whoever needs to find it.',
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|