@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,158 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { Animated, Easing, Pressable, View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { MyazaText } from '../../components/Typography';
|
|
5
|
+
import { Icon, type IconName } from '../../components/Icon';
|
|
6
|
+
import { useKyc, useKycStore, useTheme } from '../../components/runtime';
|
|
7
|
+
import { DashedBorder } from '../../components/DashedBorder';
|
|
8
|
+
import { spacing } from '../../config/theme';
|
|
9
|
+
|
|
10
|
+
// Dev/sandbox only: the address flow's Test-result tabs — the web SDK's
|
|
11
|
+
// control, on the BusinessSandboxToggle's mechanics. Four equal columns share
|
|
12
|
+
// one sliding indicator whose FILL takes the active outcome's semantic colour
|
|
13
|
+
// (the dashboard's tier/verdict language), so moving from Attested to
|
|
14
|
+
// Mismatch reads as one pill travelling and turning red. Tabs are icon-only
|
|
15
|
+
// (four labels never fit a phone) and the caption names the active pick.
|
|
16
|
+
// Nothing is SENT until the operator taps; unclicked keeps the server default,
|
|
17
|
+
// which the resting position mirrors.
|
|
18
|
+
|
|
19
|
+
type Outcome =
|
|
20
|
+
| 'address_attested'
|
|
21
|
+
| 'address_corroborated'
|
|
22
|
+
| 'address_collected'
|
|
23
|
+
| 'address_mismatch';
|
|
24
|
+
|
|
25
|
+
// `tint` is the icon's own colour while the tab is INACTIVE, so the row reads
|
|
26
|
+
// as four outcomes rather than one live state and three dead ones. The pairs
|
|
27
|
+
// are web's `text-emerald-600 dark:text-emerald-400` and friends, hex for hex;
|
|
28
|
+
// `null` takes the theme's muted tone, web's `text-muted-foreground`.
|
|
29
|
+
const OPTIONS: Array<{
|
|
30
|
+
key: Outcome;
|
|
31
|
+
label: string;
|
|
32
|
+
icon: IconName;
|
|
33
|
+
pill: string;
|
|
34
|
+
tint: { light: string; dark: string } | null;
|
|
35
|
+
}> = [
|
|
36
|
+
{
|
|
37
|
+
key: 'address_attested',
|
|
38
|
+
label: 'Attested',
|
|
39
|
+
icon: 'shield',
|
|
40
|
+
pill: '#059669',
|
|
41
|
+
tint: { light: '#059669', dark: '#34d399' },
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
key: 'address_corroborated',
|
|
45
|
+
label: 'Corroborated',
|
|
46
|
+
icon: 'badge-check',
|
|
47
|
+
pill: '#0284c7',
|
|
48
|
+
tint: { light: '#0284c7', dark: '#38bdf8' },
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
key: 'address_collected',
|
|
52
|
+
label: 'Collected',
|
|
53
|
+
icon: 'circle-dashed',
|
|
54
|
+
pill: '#475569',
|
|
55
|
+
tint: null,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
key: 'address_mismatch',
|
|
59
|
+
label: 'Mismatch',
|
|
60
|
+
icon: 'circle-x',
|
|
61
|
+
pill: '#dc2626',
|
|
62
|
+
tint: { light: '#dc2626', dark: '#f87171' },
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
export function AddressSandboxTabs(): React.ReactElement | null {
|
|
67
|
+
const store = useKycStore();
|
|
68
|
+
const { colors, mode } = useTheme();
|
|
69
|
+
const environment = useKyc((s) => s.serverConfig.environment);
|
|
70
|
+
const picked = useKyc((s) => s.addressSandboxOutcome);
|
|
71
|
+
const shown = picked ?? 'address_attested';
|
|
72
|
+
const index = Math.max(0, OPTIONS.findIndex((o) => o.key === shown));
|
|
73
|
+
const [trackWidth, setTrackWidth] = useState(0);
|
|
74
|
+
// Border box minus borders and padding, quartered — the business toggle's
|
|
75
|
+
// measurement rule, for four columns.
|
|
76
|
+
const indicatorWidth = Math.max(0, (trackWidth - 2 * 1 - 2 * 2) / 4);
|
|
77
|
+
const slide = useRef(new Animated.Value(index)).current;
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
Animated.timing(slide, {
|
|
81
|
+
toValue: index,
|
|
82
|
+
duration: 200,
|
|
83
|
+
easing: Easing.out(Easing.quad),
|
|
84
|
+
useNativeDriver: true,
|
|
85
|
+
}).start();
|
|
86
|
+
}, [index, slide]);
|
|
87
|
+
|
|
88
|
+
if (environment === 'PRODUCTION') return null;
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<View style={{ borderRadius: 12, padding: 12 }}>
|
|
92
|
+
<DashedBorder color={colors.border} radius={12} strokeWidth={1} />
|
|
93
|
+
<MyazaText variant="label">Test result</MyazaText>
|
|
94
|
+
<View style={{ height: spacing.sm }} />
|
|
95
|
+
<View
|
|
96
|
+
onLayout={(e) => setTrackWidth(e.nativeEvent.layout.width)}
|
|
97
|
+
style={{
|
|
98
|
+
flexDirection: 'row',
|
|
99
|
+
borderWidth: 1,
|
|
100
|
+
borderColor: colors.border,
|
|
101
|
+
borderRadius: 8,
|
|
102
|
+
padding: 2,
|
|
103
|
+
}}
|
|
104
|
+
>
|
|
105
|
+
{trackWidth > 0 ? (
|
|
106
|
+
<Animated.View
|
|
107
|
+
pointerEvents="none"
|
|
108
|
+
style={{
|
|
109
|
+
position: 'absolute',
|
|
110
|
+
top: 2,
|
|
111
|
+
bottom: 2,
|
|
112
|
+
left: 2,
|
|
113
|
+
width: indicatorWidth,
|
|
114
|
+
borderRadius: 6,
|
|
115
|
+
backgroundColor: OPTIONS[index]!.pill,
|
|
116
|
+
transform: [
|
|
117
|
+
{
|
|
118
|
+
translateX: slide.interpolate({
|
|
119
|
+
inputRange: [0, OPTIONS.length - 1],
|
|
120
|
+
outputRange: [0, indicatorWidth * (OPTIONS.length - 1)],
|
|
121
|
+
}),
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
}}
|
|
125
|
+
/>
|
|
126
|
+
) : null}
|
|
127
|
+
{OPTIONS.map(({ key, label, icon, tint }, i) => {
|
|
128
|
+
const active = index === i;
|
|
129
|
+
return (
|
|
130
|
+
<Pressable
|
|
131
|
+
key={key}
|
|
132
|
+
accessibilityRole="button"
|
|
133
|
+
accessibilityLabel={label}
|
|
134
|
+
accessibilityState={{ selected: active }}
|
|
135
|
+
onPress={() => store.getState().setAddressSandboxOutcome(key)}
|
|
136
|
+
style={{ flex: 1, alignItems: 'center', justifyContent: 'center', height: 40 }}
|
|
137
|
+
>
|
|
138
|
+
{/* Above the indicator, or the icon slides out from under it. */}
|
|
139
|
+
<Icon
|
|
140
|
+
name={icon}
|
|
141
|
+
size={18}
|
|
142
|
+
color={
|
|
143
|
+
active
|
|
144
|
+
? '#ffffff'
|
|
145
|
+
: (tint ? tint[mode === 'dark' ? 'dark' : 'light'] : colors.textSecondary)
|
|
146
|
+
}
|
|
147
|
+
/>
|
|
148
|
+
</Pressable>
|
|
149
|
+
);
|
|
150
|
+
})}
|
|
151
|
+
</View>
|
|
152
|
+
<View style={{ height: spacing.sm }} />
|
|
153
|
+
<MyazaText variant="bodySmall" color={colors.textSecondary}>
|
|
154
|
+
{OPTIONS[index]!.label} is returned instead of judging the pin.
|
|
155
|
+
</MyazaText>
|
|
156
|
+
</View>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { ActivityIndicator, Pressable, TextInput, View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { radius, sizing, spacing } from '../../config/theme';
|
|
5
|
+
import { useTheme } from '../../components/runtime';
|
|
6
|
+
import { useInputFontFamily } from '../../components/fonts';
|
|
7
|
+
import { Icon } from '../../components/Icon';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The address search box.
|
|
11
|
+
*
|
|
12
|
+
* Its own field rather than MyazaInput because it needs the return key to
|
|
13
|
+
* submit (the basic backend is explicit-submit only) and a trailing spinner
|
|
14
|
+
* while a details call is out — neither of which belongs on the generic input.
|
|
15
|
+
* The border, radius and height are the SAME tokens, so it reads as one family.
|
|
16
|
+
*/
|
|
17
|
+
export function AddressSearchField({
|
|
18
|
+
value,
|
|
19
|
+
onChangeText,
|
|
20
|
+
busy,
|
|
21
|
+
showSubmit,
|
|
22
|
+
canSubmit,
|
|
23
|
+
autoFocus,
|
|
24
|
+
onSubmit,
|
|
25
|
+
}: {
|
|
26
|
+
value: string;
|
|
27
|
+
onChangeText: (text: string) => void;
|
|
28
|
+
/** A request is in flight: a details call (autocomplete) or the search. */
|
|
29
|
+
busy: boolean;
|
|
30
|
+
/** The basic backend gets an explicit Search button beside the field. */
|
|
31
|
+
showSubmit: boolean;
|
|
32
|
+
canSubmit: boolean;
|
|
33
|
+
autoFocus: boolean;
|
|
34
|
+
onSubmit: () => void;
|
|
35
|
+
}): React.ReactElement {
|
|
36
|
+
const { colors } = useTheme();
|
|
37
|
+
const fontFamily = useInputFontFamily();
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<View style={{ flexDirection: 'row', alignItems: 'center', gap: spacing.sm }}>
|
|
41
|
+
<View
|
|
42
|
+
style={{
|
|
43
|
+
flex: 1,
|
|
44
|
+
flexDirection: 'row',
|
|
45
|
+
alignItems: 'center',
|
|
46
|
+
height: sizing.inputHeight,
|
|
47
|
+
paddingHorizontal: spacing.md,
|
|
48
|
+
borderWidth: 1,
|
|
49
|
+
borderColor: colors.border,
|
|
50
|
+
borderRadius: radius.sm,
|
|
51
|
+
backgroundColor: colors.background,
|
|
52
|
+
}}
|
|
53
|
+
>
|
|
54
|
+
<Icon name="search" size={16} color={colors.textSecondary} />
|
|
55
|
+
<TextInput
|
|
56
|
+
value={value}
|
|
57
|
+
onChangeText={onChangeText}
|
|
58
|
+
placeholder="Search your address, e.g. 12 Adeola Odeku Street"
|
|
59
|
+
placeholderTextColor={colors.textMuted}
|
|
60
|
+
accessibilityLabel="Search your address"
|
|
61
|
+
autoCapitalize="none"
|
|
62
|
+
// An address is a proper noun: iOS autocorrect rewrites street names
|
|
63
|
+
// right before the person submits.
|
|
64
|
+
autoCorrect={false}
|
|
65
|
+
autoFocus={autoFocus}
|
|
66
|
+
returnKeyType="search"
|
|
67
|
+
onSubmitEditing={() => {
|
|
68
|
+
if (canSubmit) onSubmit();
|
|
69
|
+
}}
|
|
70
|
+
style={{
|
|
71
|
+
flex: 1,
|
|
72
|
+
height: '100%',
|
|
73
|
+
marginLeft: spacing.sm,
|
|
74
|
+
color: colors.textDark,
|
|
75
|
+
fontSize: 16,
|
|
76
|
+
fontFamily,
|
|
77
|
+
}}
|
|
78
|
+
/>
|
|
79
|
+
{busy && !showSubmit ? <ActivityIndicator size="small" color={colors.textSecondary} /> : null}
|
|
80
|
+
</View>
|
|
81
|
+
|
|
82
|
+
{showSubmit ? (
|
|
83
|
+
<Pressable
|
|
84
|
+
onPress={canSubmit ? onSubmit : undefined}
|
|
85
|
+
disabled={!canSubmit}
|
|
86
|
+
accessibilityRole="button"
|
|
87
|
+
accessibilityLabel="Search"
|
|
88
|
+
// Web's OUTLINE button (`variant="outline" h-11 w-11 rounded-xl`),
|
|
89
|
+
// which Flutter draws too: a bordered box whose glyph takes the
|
|
90
|
+
// primary once the query is long enough, never a filled button.
|
|
91
|
+
style={{
|
|
92
|
+
width: sizing.inputHeight,
|
|
93
|
+
height: sizing.inputHeight,
|
|
94
|
+
borderRadius: radius.sm,
|
|
95
|
+
alignItems: 'center',
|
|
96
|
+
justifyContent: 'center',
|
|
97
|
+
borderWidth: 1,
|
|
98
|
+
borderColor: colors.border,
|
|
99
|
+
backgroundColor: colors.background,
|
|
100
|
+
}}
|
|
101
|
+
>
|
|
102
|
+
{busy ? (
|
|
103
|
+
<ActivityIndicator size="small" color={colors.primary} />
|
|
104
|
+
) : (
|
|
105
|
+
<Icon
|
|
106
|
+
name="search"
|
|
107
|
+
size={18}
|
|
108
|
+
color={canSubmit ? colors.primary : colors.textMuted}
|
|
109
|
+
/>
|
|
110
|
+
)}
|
|
111
|
+
</Pressable>
|
|
112
|
+
) : null}
|
|
113
|
+
</View>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import React, { useEffect } from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { spacing } from '../../config/theme';
|
|
5
|
+
import { useKyc, useKycStore, useTheme } from '../../components/runtime';
|
|
6
|
+
import { MyazaText } from '../../components/Typography';
|
|
7
|
+
import { pickedAddressState } from '../../lib/address-helpers';
|
|
8
|
+
import { useAddressFlow } from './use-address-flow';
|
|
9
|
+
import { useAddressIntroGate } from './AddressIntroGate';
|
|
10
|
+
import { SearchScreen } from './SearchScreen';
|
|
11
|
+
import { SkipForNow } from './SkipForNow';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Step 1 of the address flow: find the address as words. Every path — a picked
|
|
15
|
+
* suggestion, the current location, or "place a pin instead" — lands on the pin
|
|
16
|
+
* step.
|
|
17
|
+
*/
|
|
18
|
+
export function AddressSearchStep(): React.ReactElement {
|
|
19
|
+
const store = useKycStore();
|
|
20
|
+
const { colors } = useTheme();
|
|
21
|
+
const flow = useAddressFlow();
|
|
22
|
+
const autocomplete = useKyc((s) => s.serverConfig.addressSearchMode === 'autocomplete');
|
|
23
|
+
const gate = useAddressIntroGate('address-search', flow.steps[0]!);
|
|
24
|
+
|
|
25
|
+
// Warm the GPS and its reverse geocode from the moment the flow is reached,
|
|
26
|
+
// UNDER the primer too: by the time "Got it" is tapped the fix is usually
|
|
27
|
+
// already resolved, so the row carries the address immediately and the pin
|
|
28
|
+
// lands with no hesitation.
|
|
29
|
+
const { startPrefetch } = flow;
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
startPrefetch();
|
|
32
|
+
}, [startPrefetch]);
|
|
33
|
+
|
|
34
|
+
if (gate) return gate;
|
|
35
|
+
|
|
36
|
+
const toPin = (): void => store.getState().goToStep('address-collection');
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<View>
|
|
40
|
+
<SearchScreen
|
|
41
|
+
country={flow.country}
|
|
42
|
+
autocomplete={autocomplete}
|
|
43
|
+
near={flow.currentFix ? { lat: flow.currentFix.lat, lng: flow.currentFix.lng } : null}
|
|
44
|
+
locationHint={flow.currentFix?.label ?? null}
|
|
45
|
+
locating={flow.locating}
|
|
46
|
+
onUseMyLocation={() => void flow.applyCurrentFix(toPin)}
|
|
47
|
+
onPinInstead={toPin}
|
|
48
|
+
onResolved={(hit) => {
|
|
49
|
+
// The picked address's own country IS the declaration (a pick is
|
|
50
|
+
// the applicant saying "this is my address"), under the same
|
|
51
|
+
// guess-only / accepted-list rules as every geocode adoption.
|
|
52
|
+
flow.adoptGeocodedCountry(hit.country, { explicit: true });
|
|
53
|
+
const current = store.getState().address;
|
|
54
|
+
store.getState().setAddress({
|
|
55
|
+
...pickedAddressState(current, hit),
|
|
56
|
+
// `pickedAt` is what makes this a HUMAN-CONFIRMED label: it
|
|
57
|
+
// survives pin nudges instead of being re-derived on every drag.
|
|
58
|
+
// Never set it from a reverse geocode.
|
|
59
|
+
...(hit.formatted
|
|
60
|
+
? { label: hit.formatted, pickedAt: { lat: hit.lat, lng: hit.lng } }
|
|
61
|
+
: {}),
|
|
62
|
+
// A pick that RESOLVES a street retires the typed one: that input
|
|
63
|
+
// only existed because no source knew the street, and a hidden
|
|
64
|
+
// field must not keep leading the composed line.
|
|
65
|
+
...(hit.road ? { street: undefined } : {}),
|
|
66
|
+
// Places picks carry the breakdown; basic hits fall back to the
|
|
67
|
+
// label line in the details sheet.
|
|
68
|
+
...(hit.road || hit.area || hit.city || hit.state || hit.postcode || hit.country
|
|
69
|
+
? {
|
|
70
|
+
parts: {
|
|
71
|
+
street: hit.road ?? null,
|
|
72
|
+
area: hit.area ?? null,
|
|
73
|
+
city: hit.city ?? null,
|
|
74
|
+
state: hit.state ?? null,
|
|
75
|
+
postcode: hit.postcode ?? null,
|
|
76
|
+
country: hit.country ?? null,
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
: {}),
|
|
80
|
+
});
|
|
81
|
+
toPin();
|
|
82
|
+
}}
|
|
83
|
+
/>
|
|
84
|
+
|
|
85
|
+
{flow.error ? (
|
|
86
|
+
<MyazaText variant="bodySmall" color={colors.error} style={{ marginTop: spacing.sm }}>
|
|
87
|
+
{flow.error}
|
|
88
|
+
</MyazaText>
|
|
89
|
+
) : null}
|
|
90
|
+
|
|
91
|
+
<SkipForNow requirePin={flow.cfg?.requirePin} onPress={flow.exitForward} />
|
|
92
|
+
</View>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { ActivityIndicator, 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
|
+
|
|
9
|
+
/**
|
|
10
|
+
* "Use my current location" as a proper row, not a button pretending to be two:
|
|
11
|
+
* a medallion showing the fix's state, the RESOLVED ADDRESS underneath the
|
|
12
|
+
* title — so the person can see where it will take them before tapping — and a
|
|
13
|
+
* chevron that says this goes somewhere.
|
|
14
|
+
*
|
|
15
|
+
* Shared by the search step and by the pin step while no pin exists.
|
|
16
|
+
*/
|
|
17
|
+
export function CurrentLocationRow({
|
|
18
|
+
hint,
|
|
19
|
+
locating,
|
|
20
|
+
onPress,
|
|
21
|
+
}: {
|
|
22
|
+
/** The device's resolved current address, when known. */
|
|
23
|
+
hint: string | null;
|
|
24
|
+
locating: boolean;
|
|
25
|
+
onPress: () => void;
|
|
26
|
+
}): React.ReactElement {
|
|
27
|
+
const { colors } = useTheme();
|
|
28
|
+
return (
|
|
29
|
+
<Pressable
|
|
30
|
+
onPress={onPress}
|
|
31
|
+
accessibilityRole="button"
|
|
32
|
+
accessibilityLabel="Use my current location"
|
|
33
|
+
style={({ pressed }) => ({
|
|
34
|
+
flexDirection: 'row',
|
|
35
|
+
alignItems: 'center',
|
|
36
|
+
gap: spacing.md,
|
|
37
|
+
paddingHorizontal: spacing.md - 2,
|
|
38
|
+
paddingVertical: spacing.md - 4,
|
|
39
|
+
borderRadius: radius.md,
|
|
40
|
+
borderWidth: 1,
|
|
41
|
+
borderColor: pressed ? colors.primary : colors.border,
|
|
42
|
+
backgroundColor: pressed ? colors.primary50 : colors.backgroundSecondary,
|
|
43
|
+
})}
|
|
44
|
+
>
|
|
45
|
+
<View
|
|
46
|
+
style={{
|
|
47
|
+
width: 40,
|
|
48
|
+
height: 40,
|
|
49
|
+
borderRadius: radius.full,
|
|
50
|
+
alignItems: 'center',
|
|
51
|
+
justifyContent: 'center',
|
|
52
|
+
backgroundColor: hint ? colors.primary : colors.primary100,
|
|
53
|
+
}}
|
|
54
|
+
>
|
|
55
|
+
{locating && !hint ? (
|
|
56
|
+
<ActivityIndicator size="small" color={colors.primary} />
|
|
57
|
+
) : (
|
|
58
|
+
<Icon
|
|
59
|
+
name={hint ? 'map-pin' : 'locate'}
|
|
60
|
+
size={18}
|
|
61
|
+
color={hint ? colors.onPrimary : colors.primary}
|
|
62
|
+
/>
|
|
63
|
+
)}
|
|
64
|
+
</View>
|
|
65
|
+
<View style={{ flex: 1, minWidth: 0 }}>
|
|
66
|
+
<MyazaText variant="bodyMedium" style={{ fontWeight: '600' }}>
|
|
67
|
+
Use my current location
|
|
68
|
+
</MyazaText>
|
|
69
|
+
<MyazaText variant="bodySmall" color={colors.textSecondary} numberOfLines={1}>
|
|
70
|
+
{hint ?? (locating ? 'Finding your location…' : 'Lands the pin right where you are')}
|
|
71
|
+
</MyazaText>
|
|
72
|
+
</View>
|
|
73
|
+
<Icon name="chevron-right" size={16} color={colors.textSecondary} />
|
|
74
|
+
</Pressable>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The DEMOTED form of the row above, for once a pin exists. Locating again is
|
|
80
|
+
* then a rare corrective action, so it becomes a labelled chip ON the map,
|
|
81
|
+
* bottom centre — where the eye and thumb already are, and clear of the corners
|
|
82
|
+
* that belong to attribution and the zoom controls.
|
|
83
|
+
*
|
|
84
|
+
* Off the Continue path and deliberate to hit, where the full-width row invited
|
|
85
|
+
* a mistaken tap that yanked a confirmed address to wherever the phone was.
|
|
86
|
+
*/
|
|
87
|
+
export function LocateChip({
|
|
88
|
+
locating,
|
|
89
|
+
onPress,
|
|
90
|
+
}: {
|
|
91
|
+
locating: boolean;
|
|
92
|
+
onPress: () => void;
|
|
93
|
+
}): React.ReactElement {
|
|
94
|
+
const { colors } = useTheme();
|
|
95
|
+
return (
|
|
96
|
+
<View
|
|
97
|
+
pointerEvents="box-none"
|
|
98
|
+
style={{ position: 'absolute', left: 0, right: 0, bottom: spacing.md, alignItems: 'center' }}
|
|
99
|
+
>
|
|
100
|
+
<Pressable
|
|
101
|
+
onPress={locating ? undefined : onPress}
|
|
102
|
+
disabled={locating}
|
|
103
|
+
accessibilityRole="button"
|
|
104
|
+
accessibilityLabel="Move the pin to my current location"
|
|
105
|
+
// Web's `h-10 pl-3 pr-3.5 gap-2 shadow-md`, as Flutter draws it.
|
|
106
|
+
style={({ pressed }) => ({
|
|
107
|
+
flexDirection: 'row',
|
|
108
|
+
alignItems: 'center',
|
|
109
|
+
gap: spacing.sm,
|
|
110
|
+
height: 40,
|
|
111
|
+
paddingLeft: spacing.sm + 4,
|
|
112
|
+
paddingRight: spacing.md - 2,
|
|
113
|
+
borderRadius: radius.full,
|
|
114
|
+
borderWidth: 1,
|
|
115
|
+
borderColor: colors.border,
|
|
116
|
+
backgroundColor: pressed ? colors.backgroundSecondary : colors.background,
|
|
117
|
+
shadowColor: colors.textDark,
|
|
118
|
+
shadowOpacity: 0.12,
|
|
119
|
+
shadowRadius: 6,
|
|
120
|
+
shadowOffset: { width: 0, height: 2 },
|
|
121
|
+
elevation: 2,
|
|
122
|
+
})}
|
|
123
|
+
>
|
|
124
|
+
{locating ? (
|
|
125
|
+
<ActivityIndicator size="small" color={colors.textSecondary} />
|
|
126
|
+
) : (
|
|
127
|
+
<Icon name="locate" size={16} color={colors.primary} />
|
|
128
|
+
)}
|
|
129
|
+
<MyazaText variant="bodySmall" style={{ fontWeight: '600' }}>
|
|
130
|
+
{locating ? 'Finding you…' : 'Use my location'}
|
|
131
|
+
</MyazaText>
|
|
132
|
+
</Pressable>
|
|
133
|
+
</View>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
import { Keyboard, Platform, ScrollView, useWindowDimensions, View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { spacing } from '../../config/theme';
|
|
5
|
+
import { useTheme } from '../../components/runtime';
|
|
6
|
+
import { MyazaText } from '../../components/Typography';
|
|
7
|
+
import { MyazaButton } from '../../components/MyazaButton';
|
|
8
|
+
import { FloatingSheet } from '../../components/glass/FloatingSheet';
|
|
9
|
+
import { addressFieldModes, type AddressFieldKey } from '../../lib/address-field-modes';
|
|
10
|
+
import type { AddressCollectionConfig } from '../../types/workflow';
|
|
11
|
+
import { AreaFields, EditField, SectionHeading, type DetailPatch, type DetailValues } from './DetailsSheetFields';
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// The EDIT-DETAILS sheet, OkHi-style (user decision 2026-08-31): everything on
|
|
15
|
+
// the address is editable. Two grouped sections — street and building, then
|
|
16
|
+
// area and region — with the map's answer prefilling the area fields so the
|
|
17
|
+
// applicant corrects rather than retypes. An untouched prefill is never
|
|
18
|
+
// stored as their claim (the parent only receives what they actually edit).
|
|
19
|
+
//
|
|
20
|
+
// Each typed field honours its workflow mode (lib/address-field-modes.ts):
|
|
21
|
+
// 'off' hides it, 'required' marks it and holds the pin step's Continue.
|
|
22
|
+
// Mirrors the web SDK's DetailsSheet; keep the two in lockstep.
|
|
23
|
+
//
|
|
24
|
+
// Rides the same FloatingSheet every other picker in the SDK uses, and lifts
|
|
25
|
+
// above the keyboard like the key-person sheet, since this one is a form.
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
export type DetailsPatch = DetailPatch;
|
|
29
|
+
|
|
30
|
+
export function DetailsSheet({
|
|
31
|
+
isBusiness,
|
|
32
|
+
parts,
|
|
33
|
+
country,
|
|
34
|
+
directionsRequired,
|
|
35
|
+
addressConfig,
|
|
36
|
+
values,
|
|
37
|
+
disabled,
|
|
38
|
+
onChange,
|
|
39
|
+
onClose,
|
|
40
|
+
}: {
|
|
41
|
+
isBusiness: boolean;
|
|
42
|
+
/** The map's answer, broken down — the prefill for the area fields. */
|
|
43
|
+
parts: { street?: string | null; area?: string | null; city?: string | null; state?: string | null; postcode?: string | null } | null;
|
|
44
|
+
/** The flow's ISO-2 country (read-only: a verification fact, not a field). */
|
|
45
|
+
country: string | null;
|
|
46
|
+
directionsRequired: boolean;
|
|
47
|
+
/** The flow's address step: which typed fields are offered or required. */
|
|
48
|
+
addressConfig: AddressCollectionConfig | null | undefined;
|
|
49
|
+
values: DetailValues;
|
|
50
|
+
disabled: boolean;
|
|
51
|
+
onChange: (patch: DetailsPatch) => void;
|
|
52
|
+
onClose: () => void;
|
|
53
|
+
}): React.ReactElement {
|
|
54
|
+
const { colors } = useTheme();
|
|
55
|
+
const { height: screenHeight } = useWindowDimensions();
|
|
56
|
+
const [keyboard, setKeyboard] = useState(0);
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
const show = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
|
60
|
+
const hide = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
|
61
|
+
const shown = Keyboard.addListener(show, (e) => setKeyboard(e.endCoordinates.height));
|
|
62
|
+
const hidden = Keyboard.addListener(hide, () => setKeyboard(0));
|
|
63
|
+
return () => {
|
|
64
|
+
shown.remove();
|
|
65
|
+
hidden.remove();
|
|
66
|
+
};
|
|
67
|
+
}, []);
|
|
68
|
+
|
|
69
|
+
const available = screenHeight - keyboard;
|
|
70
|
+
const maxHeight = Math.min(available * 0.92, screenHeight * 0.85);
|
|
71
|
+
|
|
72
|
+
const modes = addressFieldModes(addressConfig);
|
|
73
|
+
const on = (k: AddressFieldKey) => modes[k] !== 'off';
|
|
74
|
+
const req = (k: AddressFieldKey) => modes[k] === 'required';
|
|
75
|
+
const anyRequired = (Object.keys(modes) as AddressFieldKey[]).some(req);
|
|
76
|
+
|
|
77
|
+
// Typed wins (a cleared field STAYS cleared); the map's answer fills the
|
|
78
|
+
// gap only while the applicant has never touched the field.
|
|
79
|
+
const shown = (typed: string | undefined, part: string | null | undefined) =>
|
|
80
|
+
typed ?? part?.trim() ?? '';
|
|
81
|
+
|
|
82
|
+
const twoCol = { flexDirection: 'row' as const, gap: spacing.sm };
|
|
83
|
+
const col = { flex: 1, minWidth: 0 };
|
|
84
|
+
const showStreetSection = on('propertyNumber') || on('street') || on('unit') || on('propertyName');
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<FloatingSheet visible onClose={onClose} maxHeight={maxHeight} bottomOffset={keyboard} closeLabel="Close">
|
|
88
|
+
<View style={{ paddingHorizontal: spacing.md, paddingTop: spacing.xs, paddingBottom: spacing.sm }}>
|
|
89
|
+
{/* One close control, not two: FloatingSheet already carries its own
|
|
90
|
+
in the same corner, and the pair read as a mis-render. */}
|
|
91
|
+
<MyazaText variant="body" style={{ fontWeight: '700' }}>
|
|
92
|
+
Edit your address
|
|
93
|
+
</MyazaText>
|
|
94
|
+
<MyazaText variant="bodySmall" color={colors.textSecondary} style={{ marginTop: 2 }}>
|
|
95
|
+
{anyRequired
|
|
96
|
+
? 'Correct anything the map got wrong. Fields marked * are required.'
|
|
97
|
+
: 'Correct anything the map got wrong. Every field is optional, and it all helps someone find the door.'}
|
|
98
|
+
</MyazaText>
|
|
99
|
+
</View>
|
|
100
|
+
|
|
101
|
+
<ScrollView
|
|
102
|
+
bounces={false}
|
|
103
|
+
keyboardShouldPersistTaps="handled"
|
|
104
|
+
contentContainerStyle={{ paddingHorizontal: spacing.md, paddingBottom: spacing.sm }}
|
|
105
|
+
>
|
|
106
|
+
{showStreetSection ? <SectionHeading>Street and building</SectionHeading> : null}
|
|
107
|
+
{on('propertyNumber') || on('street') ? (
|
|
108
|
+
<View style={twoCol}>
|
|
109
|
+
{on('propertyNumber') ? (
|
|
110
|
+
<View style={col}>
|
|
111
|
+
<EditField label="Number" value={values.propertyNumber ?? ''} placeholder="e.g. 11" maxLength={20} disabled={disabled} required={req('propertyNumber')} onChange={(propertyNumber) => onChange({ propertyNumber })} />
|
|
112
|
+
</View>
|
|
113
|
+
) : null}
|
|
114
|
+
{on('street') ? (
|
|
115
|
+
<View style={col}>
|
|
116
|
+
<EditField label="Street name" value={shown(values.street, parts?.street)} placeholder="e.g. Awolowo Road" maxLength={120} disabled={disabled} required={req('street')} onChange={(street) => onChange({ street })} />
|
|
117
|
+
</View>
|
|
118
|
+
) : null}
|
|
119
|
+
</View>
|
|
120
|
+
) : null}
|
|
121
|
+
{on('unit') || on('propertyName') ? (
|
|
122
|
+
<View style={[twoCol, { marginTop: spacing.sm }]}>
|
|
123
|
+
{on('unit') ? (
|
|
124
|
+
<View style={col}>
|
|
125
|
+
<EditField label="Unit" value={values.unit ?? ''} placeholder="e.g. Flat 4" maxLength={30} disabled={disabled} required={req('unit')} onChange={(unit) => onChange({ unit })} />
|
|
126
|
+
</View>
|
|
127
|
+
) : null}
|
|
128
|
+
{on('propertyName') ? (
|
|
129
|
+
<View style={col}>
|
|
130
|
+
<EditField label="Building name" value={values.propertyName ?? ''} placeholder="e.g. Sunrise Villa" maxLength={80} disabled={disabled} required={req('propertyName')} onChange={(propertyName) => onChange({ propertyName })} />
|
|
131
|
+
</View>
|
|
132
|
+
) : null}
|
|
133
|
+
</View>
|
|
134
|
+
) : null}
|
|
135
|
+
<View style={{ marginTop: spacing.sm }}>
|
|
136
|
+
<EditField
|
|
137
|
+
label={`${isBusiness ? 'Directions to the entrance' : 'Directions to this address'}${directionsRequired ? '' : ' (optional)'}`}
|
|
138
|
+
value={values.directions ?? ''}
|
|
139
|
+
placeholder="e.g. black gate opposite the kiosk, second building after the junction"
|
|
140
|
+
maxLength={500}
|
|
141
|
+
multiline
|
|
142
|
+
disabled={disabled}
|
|
143
|
+
onChange={(directions) => onChange({ directions })}
|
|
144
|
+
/>
|
|
145
|
+
</View>
|
|
146
|
+
|
|
147
|
+
<AreaFields modes={modes} values={values} parts={parts} shown={shown} country={country} disabled={disabled} onChange={onChange} />
|
|
148
|
+
</ScrollView>
|
|
149
|
+
|
|
150
|
+
{/* Bottom padding to match the web sheet's pb-6: the card floats clear
|
|
151
|
+
of the screen edge by 8, so 16 here lands the button the same
|
|
152
|
+
distance off the home indicator as the web drawer sits off the
|
|
153
|
+
bottom of the window. */}
|
|
154
|
+
<View style={{ paddingHorizontal: spacing.md, paddingTop: spacing.sm, paddingBottom: spacing.md }}>
|
|
155
|
+
<MyazaButton label="Done" onPress={onClose} />
|
|
156
|
+
</View>
|
|
157
|
+
</FloatingSheet>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Kept beside the sheet so the pin step's summary row and this share one
|
|
162
|
+
* shape for the "N details added" count — every editable claim counts. */
|
|
163
|
+
export function detailCount(values: Partial<DetailValues>): number {
|
|
164
|
+
return [
|
|
165
|
+
values.propertyNumber,
|
|
166
|
+
values.street,
|
|
167
|
+
values.unit,
|
|
168
|
+
values.propertyName,
|
|
169
|
+
values.directions,
|
|
170
|
+
values.neighbourhood,
|
|
171
|
+
values.city,
|
|
172
|
+
values.state,
|
|
173
|
+
values.postcode,
|
|
174
|
+
].filter((v) => v?.trim()).length;
|
|
175
|
+
}
|