@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,210 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { Animated, Pressable, View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { radius, spacing } from '../../config/theme';
|
|
5
|
+
import { useTheme } from '../../components/runtime';
|
|
6
|
+
import { Icon, type IconName } from '../../components/Icon';
|
|
7
|
+
import { MyazaText } from '../../components/Typography';
|
|
8
|
+
import { useReduceMotion } from '../../components/StaggerIn';
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// The intro screen's three plain-language disclosures — the OkHi-patterned
|
|
12
|
+
// consent copy a data protection review asks to see — as a single-open
|
|
13
|
+
// accordion.
|
|
14
|
+
//
|
|
15
|
+
// The body stays MOUNTED inside a clipped Animated.View so its natural height
|
|
16
|
+
// can be measured by onLayout and then animated; RN has no auto-height
|
|
17
|
+
// transition, and LayoutAnimation needs an experimental opt-in on Android.
|
|
18
|
+
// This uses the SDK's own Animated idiom and works the same on both platforms.
|
|
19
|
+
//
|
|
20
|
+
// Keep this copy in lockstep with the web and Flutter SDKs.
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
const HOW_IT_WORKS = {
|
|
24
|
+
foreground:
|
|
25
|
+
'After you finish, your device periodically confirms it is at this address over the coming days. Only day-level summaries ever leave your phone, never your movements.',
|
|
26
|
+
background:
|
|
27
|
+
'After you finish, your phone confirms it is at this address over the coming days, even when the app is closed. Only day-level summaries ever leave your phone, never your movements.',
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
const disclosuresFor = (
|
|
31
|
+
background: boolean,
|
|
32
|
+
): Array<{ icon: IconName; title: string; body: string }> => [
|
|
33
|
+
{
|
|
34
|
+
icon: 'circle-help',
|
|
35
|
+
title: 'How it works',
|
|
36
|
+
body: background ? HOW_IT_WORKS.background : HOW_IT_WORKS.foreground,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
icon: 'sliders',
|
|
40
|
+
title: 'You stay in control',
|
|
41
|
+
body: 'You can turn location off at any time in your device settings. An unfinished check simply expires. It never counts against you.',
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
icon: 'shield',
|
|
45
|
+
title: 'Your data is protected',
|
|
46
|
+
body: "Location summaries are used only to confirm this address and are handled under your country's data protection rules.",
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
export function IntroDisclosures({
|
|
51
|
+
background = false,
|
|
52
|
+
}: {
|
|
53
|
+
/** The workflow opts into OS geofencing: the copy says the app can be closed. */
|
|
54
|
+
background?: boolean;
|
|
55
|
+
}): React.ReactElement {
|
|
56
|
+
const { colors } = useTheme();
|
|
57
|
+
const [openIdx, setOpenIdx] = useState<number | null>(null);
|
|
58
|
+
const DISCLOSURES = disclosuresFor(background);
|
|
59
|
+
return (
|
|
60
|
+
<View
|
|
61
|
+
style={{
|
|
62
|
+
borderWidth: 1,
|
|
63
|
+
borderColor: colors.border,
|
|
64
|
+
borderRadius: radius.md,
|
|
65
|
+
overflow: 'hidden',
|
|
66
|
+
}}
|
|
67
|
+
>
|
|
68
|
+
{DISCLOSURES.map((d, i) => (
|
|
69
|
+
<DisclosureRow
|
|
70
|
+
key={d.title}
|
|
71
|
+
icon={d.icon}
|
|
72
|
+
title={d.title}
|
|
73
|
+
body={d.body}
|
|
74
|
+
open={openIdx === i}
|
|
75
|
+
divider={i < DISCLOSURES.length - 1}
|
|
76
|
+
onToggle={() => setOpenIdx(openIdx === i ? null : i)}
|
|
77
|
+
/>
|
|
78
|
+
))}
|
|
79
|
+
</View>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function DisclosureRow({
|
|
84
|
+
icon,
|
|
85
|
+
title,
|
|
86
|
+
body,
|
|
87
|
+
open,
|
|
88
|
+
divider,
|
|
89
|
+
onToggle,
|
|
90
|
+
}: {
|
|
91
|
+
icon: IconName;
|
|
92
|
+
title: string;
|
|
93
|
+
body: string;
|
|
94
|
+
open: boolean;
|
|
95
|
+
divider: boolean;
|
|
96
|
+
onToggle: () => void;
|
|
97
|
+
}): React.ReactElement {
|
|
98
|
+
const { colors } = useTheme();
|
|
99
|
+
const reduceMotion = useReduceMotion();
|
|
100
|
+
const [bodyHeight, setBodyHeight] = useState(0);
|
|
101
|
+
const progress = useRef(new Animated.Value(0)).current;
|
|
102
|
+
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (reduceMotion) {
|
|
105
|
+
progress.setValue(open ? 1 : 0);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
Animated.timing(progress, {
|
|
109
|
+
toValue: open ? 1 : 0,
|
|
110
|
+
duration: 240,
|
|
111
|
+
// Height cannot be driven natively, and the chevron rides the same value
|
|
112
|
+
// so the two stay in step.
|
|
113
|
+
useNativeDriver: false,
|
|
114
|
+
}).start();
|
|
115
|
+
}, [open, progress, reduceMotion]);
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<View style={divider ? { borderBottomWidth: 1, borderBottomColor: colors.border } : undefined}>
|
|
119
|
+
<Pressable
|
|
120
|
+
onPress={onToggle}
|
|
121
|
+
accessibilityRole="button"
|
|
122
|
+
accessibilityState={{ expanded: open }}
|
|
123
|
+
accessibilityLabel={title}
|
|
124
|
+
style={({ pressed }) => ({
|
|
125
|
+
flexDirection: 'row',
|
|
126
|
+
alignItems: 'center',
|
|
127
|
+
gap: spacing.sm,
|
|
128
|
+
paddingHorizontal: spacing.md,
|
|
129
|
+
paddingVertical: spacing.md - 2,
|
|
130
|
+
backgroundColor: pressed ? colors.backgroundSecondary : 'transparent',
|
|
131
|
+
})}
|
|
132
|
+
>
|
|
133
|
+
<View
|
|
134
|
+
style={{
|
|
135
|
+
width: 28,
|
|
136
|
+
height: 28,
|
|
137
|
+
borderRadius: radius.xs,
|
|
138
|
+
alignItems: 'center',
|
|
139
|
+
justifyContent: 'center',
|
|
140
|
+
backgroundColor: open ? colors.primary100 : colors.backgroundSecondary,
|
|
141
|
+
}}
|
|
142
|
+
>
|
|
143
|
+
<Icon name={icon} size={15} color={open ? colors.primary : colors.textSecondary} />
|
|
144
|
+
</View>
|
|
145
|
+
<MyazaText variant="bodyMedium" style={{ flex: 1, fontWeight: '500' }}>
|
|
146
|
+
{title}
|
|
147
|
+
</MyazaText>
|
|
148
|
+
<Animated.View
|
|
149
|
+
style={{
|
|
150
|
+
transform: [
|
|
151
|
+
{
|
|
152
|
+
rotate: progress.interpolate({
|
|
153
|
+
inputRange: [0, 1],
|
|
154
|
+
outputRange: ['0deg', '180deg'],
|
|
155
|
+
}),
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
}}
|
|
159
|
+
>
|
|
160
|
+
<Icon name="chevron-down" size={16} color={colors.textSecondary} />
|
|
161
|
+
</Animated.View>
|
|
162
|
+
</Pressable>
|
|
163
|
+
|
|
164
|
+
<Animated.View
|
|
165
|
+
style={{
|
|
166
|
+
overflow: 'hidden',
|
|
167
|
+
opacity: progress,
|
|
168
|
+
// 0 until the body has been measured, so nothing flashes on the
|
|
169
|
+
// first frame.
|
|
170
|
+
height: progress.interpolate({ inputRange: [0, 1], outputRange: [0, bodyHeight] }),
|
|
171
|
+
}}
|
|
172
|
+
>
|
|
173
|
+
<View
|
|
174
|
+
// Text, never a target — and out of flow it would otherwise sit over
|
|
175
|
+
// the rows below on any platform that declines to clip it.
|
|
176
|
+
pointerEvents="none"
|
|
177
|
+
// POSITIONED ABSOLUTELY, and that is the whole trick. A plain child
|
|
178
|
+
// of this clipped, zero-height box reports its natural height, which
|
|
179
|
+
// is why the pattern looks safe — but TEXT does not: it is measured
|
|
180
|
+
// inside a bounded box, so the available height of zero clamps it to
|
|
181
|
+
// zero. The row then animated open to the padding alone and every
|
|
182
|
+
// disclosure expanded to an empty strip. Out of flow the text is
|
|
183
|
+
// measured with no height constraint at all, so what comes back is
|
|
184
|
+
// the real thing.
|
|
185
|
+
onLayout={(e) => {
|
|
186
|
+
const measured = Math.round(e.nativeEvent.layout.height);
|
|
187
|
+
if (measured > 0 && measured !== bodyHeight) setBodyHeight(measured);
|
|
188
|
+
}}
|
|
189
|
+
style={{
|
|
190
|
+
position: 'absolute',
|
|
191
|
+
left: 0,
|
|
192
|
+
right: 0,
|
|
193
|
+
top: 0,
|
|
194
|
+
paddingLeft: spacing.md + 28 + spacing.sm,
|
|
195
|
+
paddingRight: spacing.md,
|
|
196
|
+
paddingBottom: spacing.md,
|
|
197
|
+
}}
|
|
198
|
+
>
|
|
199
|
+
<MyazaText
|
|
200
|
+
variant="bodyMedium"
|
|
201
|
+
color={colors.textSecondary}
|
|
202
|
+
style={{ lineHeight: 21 }}
|
|
203
|
+
>
|
|
204
|
+
{body}
|
|
205
|
+
</MyazaText>
|
|
206
|
+
</View>
|
|
207
|
+
</Animated.View>
|
|
208
|
+
</View>
|
|
209
|
+
);
|
|
210
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
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
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The keep-or-update decision after a pin move.
|
|
11
|
+
*
|
|
12
|
+
* A picked address is never silently replaced by a reverse geocode, and never
|
|
13
|
+
* silently kept against the applicant's wishes either. They choose. Rendered on
|
|
14
|
+
* the pin step only while the question is open (shouldAskLabelDecision).
|
|
15
|
+
*/
|
|
16
|
+
export function LabelDecisionRow({
|
|
17
|
+
label,
|
|
18
|
+
onKeep,
|
|
19
|
+
onAdopt,
|
|
20
|
+
}: {
|
|
21
|
+
/** The picked address line under question. */
|
|
22
|
+
label: string;
|
|
23
|
+
onKeep: () => void;
|
|
24
|
+
onAdopt: () => void;
|
|
25
|
+
}): React.ReactElement {
|
|
26
|
+
const { colors } = useTheme();
|
|
27
|
+
return (
|
|
28
|
+
<View
|
|
29
|
+
// Web's `border-primary/25 bg-primary/[0.05]`, as Flutter draws it.
|
|
30
|
+
style={{
|
|
31
|
+
borderWidth: 1,
|
|
32
|
+
borderColor: `${colors.primary}40`,
|
|
33
|
+
borderRadius: radius.md,
|
|
34
|
+
backgroundColor: `${colors.primary}0D`,
|
|
35
|
+
paddingHorizontal: spacing.md - 2,
|
|
36
|
+
paddingVertical: spacing.md - 4,
|
|
37
|
+
}}
|
|
38
|
+
>
|
|
39
|
+
<View style={{ flexDirection: 'row', alignItems: 'flex-start', gap: spacing.sm + 2 }}>
|
|
40
|
+
<View
|
|
41
|
+
style={{
|
|
42
|
+
width: 32,
|
|
43
|
+
height: 32,
|
|
44
|
+
borderRadius: radius.full,
|
|
45
|
+
alignItems: 'center',
|
|
46
|
+
justifyContent: 'center',
|
|
47
|
+
backgroundColor: `${colors.primary}1A`,
|
|
48
|
+
}}
|
|
49
|
+
>
|
|
50
|
+
<Icon name="map-pinned" size={16} color={colors.primary} />
|
|
51
|
+
</View>
|
|
52
|
+
<View style={{ flex: 1, minWidth: 0 }}>
|
|
53
|
+
<MyazaText variant="bodyMedium" style={{ fontWeight: '600' }}>
|
|
54
|
+
You moved the pin
|
|
55
|
+
</MyazaText>
|
|
56
|
+
<MyazaText variant="bodySmall" color={colors.textSecondary}>
|
|
57
|
+
Keep{' '}
|
|
58
|
+
<MyazaText variant="bodySmall" style={{ fontWeight: '600' }}>
|
|
59
|
+
{label}
|
|
60
|
+
</MyazaText>{' '}
|
|
61
|
+
as your address, or update it to match the new spot?
|
|
62
|
+
</MyazaText>
|
|
63
|
+
</View>
|
|
64
|
+
</View>
|
|
65
|
+
|
|
66
|
+
{/* Indented under the text (web pl-11). */}
|
|
67
|
+
<View style={{ flexDirection: 'row', gap: spacing.sm, marginTop: spacing.sm + 4, paddingLeft: 44 }}>
|
|
68
|
+
<DecisionButton label="Keep this address" primary onPress={onKeep} />
|
|
69
|
+
<DecisionButton label="Use the pin’s address" onPress={onAdopt} />
|
|
70
|
+
</View>
|
|
71
|
+
</View>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function DecisionButton({
|
|
76
|
+
label,
|
|
77
|
+
primary,
|
|
78
|
+
onPress,
|
|
79
|
+
}: {
|
|
80
|
+
label: string;
|
|
81
|
+
primary?: boolean;
|
|
82
|
+
onPress: () => void;
|
|
83
|
+
}): React.ReactElement {
|
|
84
|
+
const { colors } = useTheme();
|
|
85
|
+
return (
|
|
86
|
+
<Pressable
|
|
87
|
+
onPress={onPress}
|
|
88
|
+
accessibilityRole="button"
|
|
89
|
+
accessibilityLabel={label}
|
|
90
|
+
style={({ pressed }) => ({
|
|
91
|
+
flex: 1,
|
|
92
|
+
height: 36,
|
|
93
|
+
alignItems: 'center',
|
|
94
|
+
justifyContent: 'center',
|
|
95
|
+
paddingHorizontal: spacing.sm,
|
|
96
|
+
borderRadius: radius.xs,
|
|
97
|
+
borderWidth: primary ? 0 : 1,
|
|
98
|
+
borderColor: colors.primary,
|
|
99
|
+
backgroundColor: primary ? colors.primary : colors.background,
|
|
100
|
+
opacity: pressed ? 0.8 : 1,
|
|
101
|
+
})}
|
|
102
|
+
>
|
|
103
|
+
<MyazaText
|
|
104
|
+
variant="bodySmall"
|
|
105
|
+
color={primary ? colors.onPrimary : colors.primary}
|
|
106
|
+
style={{ fontWeight: '500' }}
|
|
107
|
+
numberOfLines={1}
|
|
108
|
+
>
|
|
109
|
+
{label}
|
|
110
|
+
</MyazaText>
|
|
111
|
+
</Pressable>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -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 { LineReveal, LineSkeleton } from '../../components/LineSkeleton';
|
|
9
|
+
import { ADDRESS_LINE_PENDING, ADDRESS_LINE_UNAVAILABLE } from '../../lib/address-line';
|
|
10
|
+
import { detailCount } from './DetailsSheet';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* What the pin currently says, and the way into the details sheet.
|
|
14
|
+
*
|
|
15
|
+
* The line is the composed one (displayAddressLine), the same string the review
|
|
16
|
+
* step heads its card with, so the applicant confirms exactly what they were
|
|
17
|
+
* shown here.
|
|
18
|
+
*/
|
|
19
|
+
export function PinSummaryRow({
|
|
20
|
+
line,
|
|
21
|
+
labelling,
|
|
22
|
+
values,
|
|
23
|
+
disabled,
|
|
24
|
+
onPress,
|
|
25
|
+
}: {
|
|
26
|
+
/** The composed line; '' while the pin has no readable address yet, and
|
|
27
|
+
* null when no pin has been placed at all. */
|
|
28
|
+
line: string | null;
|
|
29
|
+
/** A reverse geocode is out, so '' means "coming" rather than "none". */
|
|
30
|
+
labelling: boolean;
|
|
31
|
+
values: Parameters<typeof detailCount>[0];
|
|
32
|
+
disabled: boolean;
|
|
33
|
+
onPress: () => void;
|
|
34
|
+
}): React.ReactElement {
|
|
35
|
+
const { colors } = useTheme();
|
|
36
|
+
const count = detailCount(values);
|
|
37
|
+
// Three states, and none of them is a coordinate pair: no pin at all, a pin
|
|
38
|
+
// whose address is still being read (a skeleton line at the text's own
|
|
39
|
+
// height, so nothing moves when the words land), and a pin the geocoder had
|
|
40
|
+
// no address for.
|
|
41
|
+
const pending = line === '' && labelling;
|
|
42
|
+
const title = line === null ? 'No pin placed yet' : line || ADDRESS_LINE_UNAVAILABLE;
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<View
|
|
46
|
+
style={{
|
|
47
|
+
flexDirection: 'row',
|
|
48
|
+
alignItems: 'center',
|
|
49
|
+
gap: spacing.sm,
|
|
50
|
+
paddingHorizontal: spacing.md - 2,
|
|
51
|
+
paddingVertical: spacing.sm + 4,
|
|
52
|
+
borderWidth: 1,
|
|
53
|
+
borderColor: colors.border,
|
|
54
|
+
borderRadius: radius.md,
|
|
55
|
+
backgroundColor: colors.backgroundSecondary,
|
|
56
|
+
}}
|
|
57
|
+
>
|
|
58
|
+
<View style={{ flex: 1, minWidth: 0 }}>
|
|
59
|
+
{pending ? (
|
|
60
|
+
<LineSkeleton label={ADDRESS_LINE_PENDING} variant="bodyMedium" textStyle={{ fontWeight: '600' }} />
|
|
61
|
+
) : (
|
|
62
|
+
<LineReveal key={title}>
|
|
63
|
+
<MyazaText
|
|
64
|
+
variant="bodyMedium"
|
|
65
|
+
style={{ fontWeight: '600' }}
|
|
66
|
+
color={line ? undefined : colors.textSecondary}
|
|
67
|
+
numberOfLines={1}
|
|
68
|
+
>
|
|
69
|
+
{title}
|
|
70
|
+
</MyazaText>
|
|
71
|
+
</LineReveal>
|
|
72
|
+
)}
|
|
73
|
+
<MyazaText variant="bodySmall" color={colors.textSecondary} numberOfLines={1}>
|
|
74
|
+
{count > 0
|
|
75
|
+
? `${count} detail${count === 1 ? '' : 's'} added`
|
|
76
|
+
: 'A house number and directions help someone find it'}
|
|
77
|
+
</MyazaText>
|
|
78
|
+
</View>
|
|
79
|
+
|
|
80
|
+
<Pressable
|
|
81
|
+
onPress={disabled ? undefined : onPress}
|
|
82
|
+
disabled={disabled}
|
|
83
|
+
accessibilityRole="button"
|
|
84
|
+
accessibilityLabel="Edit details"
|
|
85
|
+
style={({ pressed }) => ({
|
|
86
|
+
flexDirection: 'row',
|
|
87
|
+
alignItems: 'center',
|
|
88
|
+
gap: spacing.xs + 2,
|
|
89
|
+
height: 36,
|
|
90
|
+
paddingHorizontal: spacing.sm + 4,
|
|
91
|
+
borderRadius: radius.xs,
|
|
92
|
+
borderWidth: 1,
|
|
93
|
+
borderColor: colors.border,
|
|
94
|
+
backgroundColor: pressed ? colors.primary50 : colors.background,
|
|
95
|
+
opacity: disabled ? 0.5 : 1,
|
|
96
|
+
})}
|
|
97
|
+
>
|
|
98
|
+
<Icon name="pencil-line" size={14} color={colors.primary} />
|
|
99
|
+
<MyazaText variant="bodyMedium" color={colors.primary} style={{ fontWeight: '600' }}>
|
|
100
|
+
Edit details
|
|
101
|
+
</MyazaText>
|
|
102
|
+
</Pressable>
|
|
103
|
+
</View>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
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 { MyazaText } from '../../components/Typography';
|
|
7
|
+
import { Icon } from '../../components/Icon';
|
|
8
|
+
import { LineSkeleton } from '../../components/LineSkeleton';
|
|
9
|
+
import { ADDRESS_LINE_PENDING, ADDRESS_LINE_UNAVAILABLE } from '../../lib/address-line';
|
|
10
|
+
|
|
11
|
+
// The address band under the review card's map (200-line split from
|
|
12
|
+
// AddressReviewStep): the success card's header-band language, so the
|
|
13
|
+
// confirmation and the "check active" card read as one design. Web's exact
|
|
14
|
+
// geometry: the band's row clears the thumbnails with pr-32 (128) and
|
|
15
|
+
// min-h-[4.25rem] (68). Flutter draws the same band. EXACTLY 128: the 12 of
|
|
16
|
+
// breathing room once added here starved the "Pinned address" pill on a
|
|
17
|
+
// 360dp phone (the S24, 2026-09-08), so the label wrapped inside its pill
|
|
18
|
+
// here and overflowed on Flutter.
|
|
19
|
+
|
|
20
|
+
const BAND_CLEARANCE = 128;
|
|
21
|
+
const BAND_MIN_HEIGHT = 68;
|
|
22
|
+
|
|
23
|
+
export function ReviewAddressBand({
|
|
24
|
+
isBusiness,
|
|
25
|
+
hasAddress,
|
|
26
|
+
line,
|
|
27
|
+
labelling,
|
|
28
|
+
directions,
|
|
29
|
+
clearsHero,
|
|
30
|
+
onEdit,
|
|
31
|
+
}: {
|
|
32
|
+
isBusiness: boolean;
|
|
33
|
+
hasAddress: boolean;
|
|
34
|
+
/** The composed line; '' while the pin has no readable address yet. */
|
|
35
|
+
line: string;
|
|
36
|
+
labelling: boolean;
|
|
37
|
+
directions: string | null;
|
|
38
|
+
/** Something hangs over the map's edge: the band clears it. */
|
|
39
|
+
clearsHero: boolean;
|
|
40
|
+
onEdit: () => void;
|
|
41
|
+
}): React.ReactElement {
|
|
42
|
+
const { colors } = useTheme();
|
|
43
|
+
return (
|
|
44
|
+
<View
|
|
45
|
+
style={{
|
|
46
|
+
backgroundColor: `${colors.primary}0F`,
|
|
47
|
+
paddingHorizontal: spacing.md,
|
|
48
|
+
paddingVertical: spacing.md,
|
|
49
|
+
paddingRight: clearsHero ? BAND_CLEARANCE : spacing.md,
|
|
50
|
+
minHeight: clearsHero ? BAND_MIN_HEIGHT : undefined,
|
|
51
|
+
borderBottomLeftRadius: radius.md - 1,
|
|
52
|
+
borderBottomRightRadius: radius.md - 1,
|
|
53
|
+
}}
|
|
54
|
+
>
|
|
55
|
+
<View style={{ flexDirection: 'row', alignItems: 'flex-start', gap: spacing.sm }}>
|
|
56
|
+
<View style={{ flex: 1, minWidth: 0 }}>
|
|
57
|
+
<View
|
|
58
|
+
style={{
|
|
59
|
+
flexDirection: 'row',
|
|
60
|
+
alignItems: 'center',
|
|
61
|
+
alignSelf: 'flex-start',
|
|
62
|
+
gap: spacing.sm,
|
|
63
|
+
maxWidth: '100%',
|
|
64
|
+
paddingHorizontal: spacing.sm + 2,
|
|
65
|
+
paddingVertical: 4,
|
|
66
|
+
borderRadius: radius.full,
|
|
67
|
+
backgroundColor: `${colors.primary}1A`,
|
|
68
|
+
}}
|
|
69
|
+
>
|
|
70
|
+
<Icon name="map-pin" size={12} color={colors.primary} />
|
|
71
|
+
{/* One line, shrinking with an ellipsis when starved (a large
|
|
72
|
+
text scale), as Flutter's pill does. */}
|
|
73
|
+
<MyazaText
|
|
74
|
+
variant="bodySmall"
|
|
75
|
+
color={colors.primary}
|
|
76
|
+
numberOfLines={1}
|
|
77
|
+
style={{ flexShrink: 1, fontSize: 11, fontWeight: '600', letterSpacing: 0.6, textTransform: 'uppercase' }}
|
|
78
|
+
>
|
|
79
|
+
{isBusiness ? 'Pinned premises' : 'Pinned address'}
|
|
80
|
+
</MyazaText>
|
|
81
|
+
</View>
|
|
82
|
+
|
|
83
|
+
{/* Never coordinates: an unread pin shows a skeleton line while
|
|
84
|
+
its address is still coming. Web's `text-base font-semibold`:
|
|
85
|
+
the body face, not a heading. */}
|
|
86
|
+
{hasAddress && !line && labelling ? (
|
|
87
|
+
<LineSkeleton label={ADDRESS_LINE_PENDING} variant="body" barHeight={12} width="70%" style={{ marginTop: spacing.xs }} />
|
|
88
|
+
) : (
|
|
89
|
+
<MyazaText variant="body" style={{ marginTop: spacing.xs, fontWeight: '600' }}>
|
|
90
|
+
{!hasAddress ? 'No pin placed' : line || ADDRESS_LINE_UNAVAILABLE}
|
|
91
|
+
</MyazaText>
|
|
92
|
+
)}
|
|
93
|
+
|
|
94
|
+
{directions ? (
|
|
95
|
+
<MyazaText variant="bodySmall" color={colors.textSecondary} style={{ marginTop: 2 }}>
|
|
96
|
+
{`“${directions}”`}
|
|
97
|
+
</MyazaText>
|
|
98
|
+
) : null}
|
|
99
|
+
</View>
|
|
100
|
+
|
|
101
|
+
{/* A plain link (web `text-sm text-primary`); the band's right
|
|
102
|
+
padding keeps it clear of the entrance hanging over it. */}
|
|
103
|
+
<Pressable onPress={onEdit} accessibilityRole="button" accessibilityLabel="Edit" hitSlop={8}>
|
|
104
|
+
<MyazaText variant="bodyMedium" color={colors.primary}>
|
|
105
|
+
Edit
|
|
106
|
+
</MyazaText>
|
|
107
|
+
</Pressable>
|
|
108
|
+
</View>
|
|
109
|
+
</View>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Image, View, type ImageSourcePropType } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { radius } from '../../config/theme';
|
|
5
|
+
import { useTheme } from '../../components/runtime';
|
|
6
|
+
|
|
7
|
+
// The entrance thumbnails on the review card (200-line split from
|
|
8
|
+
// AddressReviewStep). Web's exact geometry, so the three cards are one card:
|
|
9
|
+
// hero h-28 (112) rounded-2xl shadow-xl at -bottom-10 right-4
|
|
10
|
+
// second h-20 (80) rounded-xl shadow-lg at right-36 (144)
|
|
11
|
+
// Both share one bottom edge, 40 below the map. Flutter draws the same.
|
|
12
|
+
|
|
13
|
+
export const HERO = 112;
|
|
14
|
+
export const SECOND = 80;
|
|
15
|
+
export const OVERHANG = 40;
|
|
16
|
+
|
|
17
|
+
export function ReviewEntranceThumb({
|
|
18
|
+
source,
|
|
19
|
+
label,
|
|
20
|
+
size,
|
|
21
|
+
right,
|
|
22
|
+
onFailed,
|
|
23
|
+
}: {
|
|
24
|
+
source: ImageSourcePropType;
|
|
25
|
+
label: string;
|
|
26
|
+
size: number;
|
|
27
|
+
right: number;
|
|
28
|
+
onFailed: (error: unknown) => void;
|
|
29
|
+
}): React.ReactElement {
|
|
30
|
+
const { colors } = useTheme();
|
|
31
|
+
// The hero wears the card's radius (16), the smaller one a step down (12).
|
|
32
|
+
const r = size === HERO ? radius.md : radius.sm;
|
|
33
|
+
return (
|
|
34
|
+
// The shadow lives on a wrapper with a background of its own: an Image
|
|
35
|
+
// cannot cast one on Android, and iOS needs an opaque shape to draw it
|
|
36
|
+
// from. zIndex 20, as web spells it, so the half hanging over the band is
|
|
37
|
+
// not covered by it.
|
|
38
|
+
<View
|
|
39
|
+
style={{
|
|
40
|
+
position: 'absolute',
|
|
41
|
+
zIndex: 20,
|
|
42
|
+
right,
|
|
43
|
+
bottom: -OVERHANG,
|
|
44
|
+
width: size,
|
|
45
|
+
height: size,
|
|
46
|
+
borderRadius: r,
|
|
47
|
+
backgroundColor: colors.background,
|
|
48
|
+
shadowColor: colors.textDark,
|
|
49
|
+
shadowOpacity: 0.18,
|
|
50
|
+
shadowRadius: 12,
|
|
51
|
+
shadowOffset: { width: 0, height: 4 },
|
|
52
|
+
elevation: 6,
|
|
53
|
+
}}
|
|
54
|
+
>
|
|
55
|
+
<Image
|
|
56
|
+
source={source}
|
|
57
|
+
accessibilityLabel={label}
|
|
58
|
+
onError={(e) => onFailed(e.nativeEvent?.error)}
|
|
59
|
+
style={{ width: size, height: size, borderRadius: r, borderWidth: 4, borderColor: colors.background }}
|
|
60
|
+
resizeMode="cover"
|
|
61
|
+
/>
|
|
62
|
+
</View>
|
|
63
|
+
);
|
|
64
|
+
}
|