@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,184 @@
|
|
|
1
|
+
import React, { useMemo, useState } from 'react';
|
|
2
|
+
import { Pressable, ScrollView, View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { radius, spacing } from '../config/theme';
|
|
5
|
+
import { groupCountriesByRegion, pinGeoRow, regionCountryName } from '../config/regions';
|
|
6
|
+
import { useTheme } from './runtime';
|
|
7
|
+
import { MyazaText } from './Typography';
|
|
8
|
+
import { MyazaInput } from './MyazaInput';
|
|
9
|
+
import { CountryFlag } from './CountryFlag';
|
|
10
|
+
import { Icon } from './Icon';
|
|
11
|
+
import { GeoBadge } from './GeoBadge';
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Searchable, region-grouped country picker for the country-select step. Used
|
|
15
|
+
// when a workflow offers many countries (Global Documents): a flat 200-country
|
|
16
|
+
// list is a scroll, not a chooser, so this adds a pinned search box and
|
|
17
|
+
// continent headers over a bounded scroll area. Mirrors the web SDK's
|
|
18
|
+
// CountryRegionPicker and Flutter's country_region_picker.dart.
|
|
19
|
+
//
|
|
20
|
+
// Split from CountrySelectStep (200-line rule); the step keeps the flat list
|
|
21
|
+
// and the threshold that decides between the two.
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
export function CountryRegionPicker({
|
|
25
|
+
countries,
|
|
26
|
+
selected,
|
|
27
|
+
geoCountry,
|
|
28
|
+
onPick,
|
|
29
|
+
}: {
|
|
30
|
+
countries: string[];
|
|
31
|
+
selected: string | null;
|
|
32
|
+
/**
|
|
33
|
+
* The visitor's IP country. Lifted out of its continent to the very top and
|
|
34
|
+
* tagged, so the one country most likely to be theirs is the first thing
|
|
35
|
+
* they see rather than something to scroll for.
|
|
36
|
+
*/
|
|
37
|
+
geoCountry?: string | null;
|
|
38
|
+
onPick: (country: string) => void;
|
|
39
|
+
}): React.ReactElement {
|
|
40
|
+
const { colors } = useTheme();
|
|
41
|
+
const [query, setQuery] = useState('');
|
|
42
|
+
|
|
43
|
+
const { pinned, groups } = useMemo(() => {
|
|
44
|
+
const needle = query.trim().toLowerCase();
|
|
45
|
+
const visible = needle
|
|
46
|
+
? countries.filter(
|
|
47
|
+
(code) =>
|
|
48
|
+
code.toLowerCase().includes(needle) ||
|
|
49
|
+
regionCountryName(code).toLowerCase().includes(needle),
|
|
50
|
+
)
|
|
51
|
+
: countries;
|
|
52
|
+
const split = pinGeoRow(visible, geoCountry, (code) => code);
|
|
53
|
+
return { pinned: split.pinned, groups: groupCountriesByRegion(split.rest) };
|
|
54
|
+
}, [countries, query, geoCountry]);
|
|
55
|
+
|
|
56
|
+
const empty = groups.length === 0 && pinned === null;
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
// The search box stays put and the list owns its own scroll, so a
|
|
60
|
+
// ~240-country flow does not scroll the search field off the top. That
|
|
61
|
+
// relies on the SHEET rendering this step with `fillsViewport` — inside a
|
|
62
|
+
// scroll view the nested list below would take an unbounded height, outgrow
|
|
63
|
+
// the viewport, and drag the search box off the top with it.
|
|
64
|
+
<View style={{ flex: 1 }}>
|
|
65
|
+
<MyazaInput
|
|
66
|
+
value={query}
|
|
67
|
+
onChangeText={setQuery}
|
|
68
|
+
placeholder="Search countries…"
|
|
69
|
+
autoCapitalize="none"
|
|
70
|
+
prefix={<Icon name="search" size={18} color={colors.textSecondary} />}
|
|
71
|
+
/>
|
|
72
|
+
<View style={{ height: spacing.md }} />
|
|
73
|
+
|
|
74
|
+
{empty ? (
|
|
75
|
+
<MyazaText variant="bodyMedium" style={{ textAlign: 'center', marginTop: spacing.lg }}>
|
|
76
|
+
No countries match your search.
|
|
77
|
+
</MyazaText>
|
|
78
|
+
) : (
|
|
79
|
+
// `nestedScrollEnabled` stays: Android refuses to scroll a nested
|
|
80
|
+
// same-direction list without it, and this is still nested on the
|
|
81
|
+
// flat-list path and inside any future scrolling parent.
|
|
82
|
+
<ScrollView
|
|
83
|
+
nestedScrollEnabled
|
|
84
|
+
keyboardShouldPersistTaps="handled"
|
|
85
|
+
showsVerticalScrollIndicator={false}
|
|
86
|
+
>
|
|
87
|
+
{pinned ? (
|
|
88
|
+
<CountryRow
|
|
89
|
+
code={pinned}
|
|
90
|
+
name={regionCountryName(pinned)}
|
|
91
|
+
selected={pinned === selected}
|
|
92
|
+
badge="Your location"
|
|
93
|
+
onPress={() => onPick(pinned)}
|
|
94
|
+
/>
|
|
95
|
+
) : null}
|
|
96
|
+
{groups.map((group) => (
|
|
97
|
+
<View key={group.region}>
|
|
98
|
+
{/* Uppercase, tracked-out section label — Flutter's region
|
|
99
|
+
header. Lowercase text at the default weight read as another
|
|
100
|
+
list row rather than a divider. */}
|
|
101
|
+
<MyazaText
|
|
102
|
+
variant="bodySmall"
|
|
103
|
+
color={colors.textSecondary}
|
|
104
|
+
style={{
|
|
105
|
+
fontWeight: '700',
|
|
106
|
+
letterSpacing: 0.6,
|
|
107
|
+
paddingHorizontal: spacing.xs,
|
|
108
|
+
paddingVertical: spacing.sm,
|
|
109
|
+
}}
|
|
110
|
+
>
|
|
111
|
+
{group.region.toUpperCase()}
|
|
112
|
+
</MyazaText>
|
|
113
|
+
{group.countries.map((entry) => (
|
|
114
|
+
<CountryRow
|
|
115
|
+
key={entry.code}
|
|
116
|
+
code={entry.code}
|
|
117
|
+
name={entry.name}
|
|
118
|
+
selected={entry.code === selected}
|
|
119
|
+
onPress={() => onPick(entry.code)}
|
|
120
|
+
/>
|
|
121
|
+
))}
|
|
122
|
+
</View>
|
|
123
|
+
))}
|
|
124
|
+
</ScrollView>
|
|
125
|
+
)}
|
|
126
|
+
</View>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* One selectable country row, shared by the flat list and the region picker
|
|
132
|
+
* so the two cannot drift apart. `badge` tags the pinned geo row; it is the
|
|
133
|
+
* same control in a second position, differing only by the tag.
|
|
134
|
+
*/
|
|
135
|
+
export function CountryRow({
|
|
136
|
+
code,
|
|
137
|
+
name,
|
|
138
|
+
selected,
|
|
139
|
+
badge,
|
|
140
|
+
onPress,
|
|
141
|
+
}: {
|
|
142
|
+
code: string;
|
|
143
|
+
name: string;
|
|
144
|
+
selected: boolean;
|
|
145
|
+
badge?: string;
|
|
146
|
+
onPress: () => void;
|
|
147
|
+
}): React.ReactElement {
|
|
148
|
+
const { colors } = useTheme();
|
|
149
|
+
return (
|
|
150
|
+
<Pressable
|
|
151
|
+
onPress={onPress}
|
|
152
|
+
accessibilityRole="radio"
|
|
153
|
+
accessibilityState={{ checked: selected }}
|
|
154
|
+
accessibilityLabel={badge ? `${name}, ${badge.toLowerCase()}` : name}
|
|
155
|
+
// Matches the Flutter SDK's CountryOptionTile exactly — the two had
|
|
156
|
+
// drifted (24px flag / 12px padding here vs 32 / 16 there), which is why
|
|
157
|
+
// the same screen looked like a different component on each platform. The
|
|
158
|
+
// spacing and radius scales are identical across the SDKs, so there is
|
|
159
|
+
// nothing platform-specific to reconcile: `md` means 16 on both.
|
|
160
|
+
style={{
|
|
161
|
+
flexDirection: 'row',
|
|
162
|
+
alignItems: 'center',
|
|
163
|
+
padding: spacing.md,
|
|
164
|
+
borderRadius: radius.md,
|
|
165
|
+
borderWidth: selected ? 1.5 : 1,
|
|
166
|
+
borderColor: selected ? colors.primary : colors.border,
|
|
167
|
+
backgroundColor: selected ? colors.primary50 : colors.background,
|
|
168
|
+
marginBottom: spacing.sm,
|
|
169
|
+
}}
|
|
170
|
+
>
|
|
171
|
+
<CountryFlag country={code} size={32} />
|
|
172
|
+
<View style={{ width: 12 }} />
|
|
173
|
+
<MyazaText variant="label" style={{ flex: 1, fontWeight: '500' }}>
|
|
174
|
+
{name}
|
|
175
|
+
</MyazaText>
|
|
176
|
+
{badge ? <GeoBadge label={badge} /> : null}
|
|
177
|
+
<Icon
|
|
178
|
+
name="chevron-right"
|
|
179
|
+
size={18}
|
|
180
|
+
color={selected ? colors.primary : colors.textSecondary}
|
|
181
|
+
/>
|
|
182
|
+
</Pressable>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
@@ -13,8 +13,9 @@ import { useTheme } from './runtime';
|
|
|
13
13
|
import { MyazaText } from './Typography';
|
|
14
14
|
import { MyazaInput } from './MyazaInput';
|
|
15
15
|
import { Icon } from './Icon';
|
|
16
|
-
import { CountryFlag } from './CountryFlag';
|
|
17
16
|
import { FloatingSheet } from './glass/FloatingSheet';
|
|
17
|
+
import { DialCodeDivider, DialCodeRegionHeader, DialCodeRow } from './DialCodeRow';
|
|
18
|
+
import { buildDialCodeItems, dialCodeItemKey, filterDialCodeOptions, type DialCodeItem } from './dialCodeRows';
|
|
18
19
|
|
|
19
20
|
// ---------------------------------------------------------------------------
|
|
20
21
|
// THE country sheet — the phone field's dial-code picker, generalised.
|
|
@@ -33,6 +34,10 @@ import { FloatingSheet } from './glass/FloatingSheet';
|
|
|
33
34
|
// The sheet is sized against the space left ABOVE the keyboard, like Flutter's.
|
|
34
35
|
// The search field autofocuses, so measuring against the full screen would put
|
|
35
36
|
// every result underneath the keys the moment the user started typing.
|
|
37
|
+
//
|
|
38
|
+
// `grouped` lists the countries under region headers (Africa first), the way
|
|
39
|
+
// the country-select step does — what the address-scope country control asks
|
|
40
|
+
// for. The list itself is built by dialCodeRows.ts.
|
|
36
41
|
// ---------------------------------------------------------------------------
|
|
37
42
|
|
|
38
43
|
export interface DialCodeOption {
|
|
@@ -46,6 +51,8 @@ export function DialCodePicker({
|
|
|
46
51
|
visible,
|
|
47
52
|
options,
|
|
48
53
|
selected,
|
|
54
|
+
pinned,
|
|
55
|
+
grouped = false,
|
|
49
56
|
onPick,
|
|
50
57
|
onClose,
|
|
51
58
|
searchPlaceholder = 'Search country or code',
|
|
@@ -53,6 +60,14 @@ export function DialCodePicker({
|
|
|
53
60
|
visible: boolean;
|
|
54
61
|
options: DialCodeOption[];
|
|
55
62
|
selected: string;
|
|
63
|
+
/**
|
|
64
|
+
* The visitor's IP country. Lifted out of the alphabet to the top and
|
|
65
|
+
* tagged, so a guess we made on their behalf is visible AS a guess and one
|
|
66
|
+
* tap away rather than buried among two hundred others.
|
|
67
|
+
*/
|
|
68
|
+
pinned?: string | null;
|
|
69
|
+
/** Region headers between the rows (the pinned row stays on top). */
|
|
70
|
+
grouped?: boolean;
|
|
56
71
|
onPick: (code: string) => void;
|
|
57
72
|
onClose: () => void;
|
|
58
73
|
searchPlaceholder?: string;
|
|
@@ -78,19 +93,13 @@ export function DialCodePicker({
|
|
|
78
93
|
const available = screenHeight - keyboard;
|
|
79
94
|
const maxHeight = Math.min(Math.max(available * 0.85, 240), screenHeight * 0.6);
|
|
80
95
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
(o) =>
|
|
89
|
-
o.name.toLowerCase().includes(q) ||
|
|
90
|
-
o.code.toLowerCase() === q ||
|
|
91
|
-
(o.dialCode != null && o.dialCode.replace(/^\+/, '').startsWith(bare)),
|
|
92
|
-
);
|
|
93
|
-
}, [options, query]);
|
|
96
|
+
// Where they appear to be, lifted out of the alphabet. It stays subject to
|
|
97
|
+
// the search, so typing still narrows to what was asked for rather than
|
|
98
|
+
// keeping a row that does not match.
|
|
99
|
+
const rows = useMemo(
|
|
100
|
+
() => buildDialCodeItems(filterDialCodeOptions(options, query), pinned, grouped),
|
|
101
|
+
[options, query, pinned, grouped],
|
|
102
|
+
);
|
|
94
103
|
|
|
95
104
|
const close = (): void => {
|
|
96
105
|
setQuery('');
|
|
@@ -121,67 +130,37 @@ export function DialCodePicker({
|
|
|
121
130
|
</View>
|
|
122
131
|
|
|
123
132
|
<FlatList
|
|
124
|
-
data={
|
|
125
|
-
keyExtractor={
|
|
133
|
+
data={rows.items}
|
|
134
|
+
keyExtractor={dialCodeItemKey}
|
|
126
135
|
keyboardShouldPersistTaps="handled"
|
|
136
|
+
// A hairline under the pinned row only: the alphabet below it is one
|
|
137
|
+
// list, and a rule between every row is noise.
|
|
138
|
+
ItemSeparatorComponent={({ leadingItem }: { leadingItem: DialCodeItem }) =>
|
|
139
|
+
rows.geo != null && leadingItem.kind === 'row' && leadingItem.option.code === rows.geo.code ? (
|
|
140
|
+
<DialCodeDivider />
|
|
141
|
+
) : null
|
|
142
|
+
}
|
|
127
143
|
ListEmptyComponent={
|
|
128
144
|
<MyazaText variant="bodyMedium" style={{ padding: spacing.lg, textAlign: 'center' }}>
|
|
129
145
|
No countries match your search.
|
|
130
146
|
</MyazaText>
|
|
131
147
|
}
|
|
132
|
-
renderItem={({ item }) =>
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
148
|
+
renderItem={({ item }) =>
|
|
149
|
+
item.kind === 'header' ? (
|
|
150
|
+
<DialCodeRegionHeader region={item.region} />
|
|
151
|
+
) : (
|
|
152
|
+
<DialCodeRow
|
|
153
|
+
option={item.option}
|
|
154
|
+
isSelected={item.option.code === selected}
|
|
155
|
+
badge={rows.geo != null && item.option.code === rows.geo.code ? 'Your location' : undefined}
|
|
156
|
+
onPress={() => {
|
|
157
|
+
setQuery('');
|
|
158
|
+
onPick(item.option.code);
|
|
159
|
+
}}
|
|
160
|
+
/>
|
|
161
|
+
)
|
|
162
|
+
}
|
|
142
163
|
/>
|
|
143
164
|
</FloatingSheet>
|
|
144
165
|
);
|
|
145
166
|
}
|
|
146
|
-
|
|
147
|
-
function CountryRow({
|
|
148
|
-
option,
|
|
149
|
-
isSelected,
|
|
150
|
-
onPress,
|
|
151
|
-
}: {
|
|
152
|
-
option: DialCodeOption;
|
|
153
|
-
isSelected: boolean;
|
|
154
|
-
onPress: () => void;
|
|
155
|
-
}): React.ReactElement {
|
|
156
|
-
const { colors } = useTheme();
|
|
157
|
-
return (
|
|
158
|
-
<Pressable
|
|
159
|
-
onPress={onPress}
|
|
160
|
-
accessibilityRole="radio"
|
|
161
|
-
accessibilityState={{ checked: isSelected }}
|
|
162
|
-
style={{
|
|
163
|
-
flexDirection: 'row',
|
|
164
|
-
alignItems: 'center',
|
|
165
|
-
paddingHorizontal: spacing.md,
|
|
166
|
-
// Roomier rows: a country list is a long scan, and cramped rows make
|
|
167
|
-
// every tap a precision job. Matches the web dropdown's row height.
|
|
168
|
-
paddingVertical: 12,
|
|
169
|
-
backgroundColor: isSelected ? colors.primary50 : 'transparent',
|
|
170
|
-
}}
|
|
171
|
-
>
|
|
172
|
-
<CountryFlag country={option.code} size={28} />
|
|
173
|
-
<View style={{ width: spacing.md }} />
|
|
174
|
-
{/* No line clamp: "Bosnia & Herzegovina" and the like must read in full,
|
|
175
|
-
which is what Flutter's Expanded(Text) gives. Full body size (16),
|
|
176
|
-
the web dropdown's reading size — 14 read small against the flags. */}
|
|
177
|
-
<MyazaText variant="body" style={{ flex: 1 }}>
|
|
178
|
-
{option.name}
|
|
179
|
-
</MyazaText>
|
|
180
|
-
{option.dialCode != null ? (
|
|
181
|
-
<MyazaText variant="bodyMedium" color={colors.textSecondary}>
|
|
182
|
-
{option.dialCode}
|
|
183
|
-
</MyazaText>
|
|
184
|
-
) : null}
|
|
185
|
-
</Pressable>
|
|
186
|
-
);
|
|
187
|
-
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Pressable, StyleSheet, View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { spacing } from '../config/theme';
|
|
5
|
+
import { useTheme } from './runtime';
|
|
6
|
+
import { MyazaText } from './Typography';
|
|
7
|
+
import { CountryFlag } from './CountryFlag';
|
|
8
|
+
import { GeoBadge } from './GeoBadge';
|
|
9
|
+
import type { DialCodeOption } from './DialCodePicker';
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// One country in the dial-code sheet. Extracted from DialCodePicker (200-line
|
|
13
|
+
// rule) and shared by the pinned geo row and the alphabetical ones, so the two
|
|
14
|
+
// cannot drift apart: they are the same control in two positions, and the
|
|
15
|
+
// pinned one only differs by carrying a tag and a hairline beneath it.
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
export function DialCodeRow({
|
|
19
|
+
option,
|
|
20
|
+
isSelected,
|
|
21
|
+
badge,
|
|
22
|
+
onPress,
|
|
23
|
+
}: {
|
|
24
|
+
option: DialCodeOption;
|
|
25
|
+
isSelected: boolean;
|
|
26
|
+
/** Tags the pinned geo row ("Your location"). */
|
|
27
|
+
badge?: string;
|
|
28
|
+
onPress: () => void;
|
|
29
|
+
}): React.ReactElement {
|
|
30
|
+
const { colors } = useTheme();
|
|
31
|
+
return (
|
|
32
|
+
<Pressable
|
|
33
|
+
onPress={onPress}
|
|
34
|
+
accessibilityRole="radio"
|
|
35
|
+
accessibilityState={{ checked: isSelected }}
|
|
36
|
+
accessibilityLabel={badge ? `${option.name}, ${badge.toLowerCase()}` : option.name}
|
|
37
|
+
style={{
|
|
38
|
+
flexDirection: 'row',
|
|
39
|
+
alignItems: 'center',
|
|
40
|
+
paddingHorizontal: spacing.md,
|
|
41
|
+
// Roomier rows: a country list is a long scan, and cramped rows make
|
|
42
|
+
// every tap a precision job. Matches the web dropdown's row height.
|
|
43
|
+
paddingVertical: 12,
|
|
44
|
+
backgroundColor: isSelected ? colors.primary50 : 'transparent',
|
|
45
|
+
}}
|
|
46
|
+
>
|
|
47
|
+
<CountryFlag country={option.code} size={28} />
|
|
48
|
+
<View style={{ width: spacing.md }} />
|
|
49
|
+
{/* No line clamp: "Bosnia & Herzegovina" and the like must read in full,
|
|
50
|
+
which is what Flutter's Expanded(Text) gives. Full body size (16),
|
|
51
|
+
the web dropdown's reading size — 14 read small against the flags. */}
|
|
52
|
+
<MyazaText variant="body" style={{ flex: 1 }}>
|
|
53
|
+
{option.name}
|
|
54
|
+
</MyazaText>
|
|
55
|
+
{badge ? <GeoBadge label={badge} /> : null}
|
|
56
|
+
{option.dialCode != null ? (
|
|
57
|
+
<MyazaText variant="bodyMedium" color={colors.textSecondary}>
|
|
58
|
+
{option.dialCode}
|
|
59
|
+
</MyazaText>
|
|
60
|
+
) : null}
|
|
61
|
+
</Pressable>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A region label between grouped rows — the country-select step's header
|
|
67
|
+
* (uppercase, tracked out), so the two pickers read as one control.
|
|
68
|
+
*/
|
|
69
|
+
export function DialCodeRegionHeader({ region }: { region: string }): React.ReactElement {
|
|
70
|
+
const { colors } = useTheme();
|
|
71
|
+
return (
|
|
72
|
+
<MyazaText
|
|
73
|
+
variant="bodySmall"
|
|
74
|
+
color={colors.textSecondary}
|
|
75
|
+
accessibilityRole="header"
|
|
76
|
+
style={{
|
|
77
|
+
fontWeight: '700',
|
|
78
|
+
letterSpacing: 0.6,
|
|
79
|
+
paddingHorizontal: spacing.md,
|
|
80
|
+
paddingTop: spacing.md,
|
|
81
|
+
paddingBottom: spacing.xs,
|
|
82
|
+
}}
|
|
83
|
+
>
|
|
84
|
+
{region.toUpperCase()}
|
|
85
|
+
</MyazaText>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The hairline under the pinned row, separating it from the alphabet. */
|
|
90
|
+
export function DialCodeDivider(): React.ReactElement {
|
|
91
|
+
const { colors } = useTheme();
|
|
92
|
+
return (
|
|
93
|
+
<View
|
|
94
|
+
style={{
|
|
95
|
+
marginHorizontal: spacing.md,
|
|
96
|
+
marginVertical: spacing.xs,
|
|
97
|
+
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
98
|
+
borderBottomColor: colors.border,
|
|
99
|
+
}}
|
|
100
|
+
/>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { ActivityIndicator, View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { radius } from '../config/theme';
|
|
5
|
+
import { loadWebView, type WebViewLike } from '../lib/webview-available';
|
|
6
|
+
import { useTheme } from './runtime';
|
|
7
|
+
import { MapPinPicker } from './MapPinPicker';
|
|
8
|
+
import {
|
|
9
|
+
MAP_FRAME_READY_TIMEOUT_MS,
|
|
10
|
+
buildMapFrameSrc,
|
|
11
|
+
centerCommandScript,
|
|
12
|
+
parseMapFrameMessage,
|
|
13
|
+
samePin,
|
|
14
|
+
} from '../lib/map-frame';
|
|
15
|
+
import type { LatLng } from '../lib/map-tiles';
|
|
16
|
+
|
|
17
|
+
// Google Maps on mobile, via OUR hosted /embed/map page in a WebView (the OkHi
|
|
18
|
+
// model — the map runs on the hosted origin, on Myaza's own key; the host app
|
|
19
|
+
// never brings one). Mirrors the web SDK's FramedMapPicker: a page that never
|
|
20
|
+
// says `ready` (no webview module installed, blocked script, refused grant,
|
|
21
|
+
// vendor outage) falls back to the dependency-free OSM picker, so the step can
|
|
22
|
+
// never go blank. `react-native-webview` is an OPTIONAL peer: absent, this
|
|
23
|
+
// component IS the OSM picker.
|
|
24
|
+
|
|
25
|
+
interface FramedMapPickerProps {
|
|
26
|
+
/** The server-minted page URL; null ⇒ the OSM picker outright. */
|
|
27
|
+
frameUrl: string | null;
|
|
28
|
+
value: LatLng | null;
|
|
29
|
+
/** Corner rounding. 0 where a parent already shapes the frame — the review
|
|
30
|
+
* card rounds its TOP corners only, and the picker's own radius put a curve
|
|
31
|
+
* where the map meets the address band. */
|
|
32
|
+
cornerRadius?: number;
|
|
33
|
+
onChange: (pin: LatLng) => void;
|
|
34
|
+
defaultCenter: LatLng;
|
|
35
|
+
defaultZoom: number;
|
|
36
|
+
height?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function FramedMapPicker({ frameUrl, value, onChange, defaultCenter, defaultZoom, height = 300, cornerRadius }: FramedMapPickerProps): React.ReactElement {
|
|
40
|
+
const WebView = useMemo(loadWebView, []);
|
|
41
|
+
const { colors, mode } = useTheme();
|
|
42
|
+
const [ready, setReady] = useState(false);
|
|
43
|
+
const [failed, setFailed] = useState(!frameUrl || !WebView);
|
|
44
|
+
const readyRef = useRef(false);
|
|
45
|
+
const webRef = useRef<WebViewLike>(null);
|
|
46
|
+
const onChangeRef = useRef(onChange);
|
|
47
|
+
onChangeRef.current = onChange;
|
|
48
|
+
const lastFromFrame = useRef<LatLng | null>(null);
|
|
49
|
+
|
|
50
|
+
// Built once per mount: a src that tracked the pin would reload the page per drag.
|
|
51
|
+
const src = useMemo(
|
|
52
|
+
() =>
|
|
53
|
+
frameUrl
|
|
54
|
+
? buildMapFrameSrc(frameUrl, {
|
|
55
|
+
center: value ?? defaultCenter,
|
|
56
|
+
zoom: defaultZoom,
|
|
57
|
+
hasPin: value != null,
|
|
58
|
+
theme: mode === 'dark' ? 'dark' : 'light',
|
|
59
|
+
primaryColor: colors.primary,
|
|
60
|
+
})
|
|
61
|
+
: null,
|
|
62
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
63
|
+
[frameUrl],
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (failed) return undefined;
|
|
68
|
+
const timeout = setTimeout(() => {
|
|
69
|
+
if (!readyRef.current) setFailed(true);
|
|
70
|
+
}, MAP_FRAME_READY_TIMEOUT_MS);
|
|
71
|
+
return () => clearTimeout(timeout);
|
|
72
|
+
}, [failed]);
|
|
73
|
+
|
|
74
|
+
// An external recentre (Use my location, restored progress) moves the map —
|
|
75
|
+
// but never echo back a pin the page itself just reported.
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (!value || !ready || samePin(lastFromFrame.current, value)) return;
|
|
78
|
+
webRef.current?.injectJavaScript(centerCommandScript(value));
|
|
79
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
80
|
+
}, [value?.lat, value?.lng, ready]);
|
|
81
|
+
|
|
82
|
+
if (failed || !WebView || !src) {
|
|
83
|
+
return <MapPinPicker value={value} onChange={onChange} defaultCenter={defaultCenter} defaultZoom={defaultZoom} height={height} cornerRadius={cornerRadius} />;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<View style={{ height, borderRadius: cornerRadius ?? radius.md, overflow: 'hidden', backgroundColor: colors.backgroundSecondary }}>
|
|
88
|
+
<WebView
|
|
89
|
+
ref={webRef}
|
|
90
|
+
source={{ uri: src }}
|
|
91
|
+
style={{ flex: 1, backgroundColor: 'transparent' }}
|
|
92
|
+
javaScriptEnabled
|
|
93
|
+
// Android: this WebView sits inside StickyActions' ScrollView, and a
|
|
94
|
+
// ScrollView intercepts every vertical drag its children start, so the
|
|
95
|
+
// map moved a few pixels and stopped while the page scrolled instead.
|
|
96
|
+
// nestedScrollEnabled makes the WebView ask the parent not to intercept
|
|
97
|
+
// (requestDisallowInterceptTouchEvent) for the life of the touch. No-op
|
|
98
|
+
// on iOS. Reported on a Galaxy S24, 2026-09-07.
|
|
99
|
+
nestedScrollEnabled
|
|
100
|
+
onError={() => setFailed(true)}
|
|
101
|
+
onMessage={(event) => {
|
|
102
|
+
const msg = parseMapFrameMessage(event.nativeEvent.data);
|
|
103
|
+
if (!msg) return;
|
|
104
|
+
if (msg.type === 'ready') {
|
|
105
|
+
readyRef.current = true;
|
|
106
|
+
setReady(true);
|
|
107
|
+
} else if (msg.type === 'failed') {
|
|
108
|
+
setFailed(true);
|
|
109
|
+
} else {
|
|
110
|
+
lastFromFrame.current = { lat: msg.lat, lng: msg.lng };
|
|
111
|
+
onChangeRef.current({ lat: msg.lat, lng: msg.lng });
|
|
112
|
+
}
|
|
113
|
+
}}
|
|
114
|
+
/>
|
|
115
|
+
{!ready ? (
|
|
116
|
+
<View pointerEvents="none" style={{ position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, alignItems: 'center', justifyContent: 'center' }}>
|
|
117
|
+
<ActivityIndicator size="small" color={colors.primary} />
|
|
118
|
+
</View>
|
|
119
|
+
) : null}
|
|
120
|
+
</View>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { radius, spacing } from '../config/theme';
|
|
5
|
+
import { useTheme } from './runtime';
|
|
6
|
+
import { MyazaText } from './Typography';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The "Your location" pill beside a pinned geo row: primary tint, primary
|
|
10
|
+
* text. It marks a GUESS made on the visitor's behalf, never a verdict, and
|
|
11
|
+
* the row it sits on is one tap away rather than buried in the alphabet.
|
|
12
|
+
* Shared by the country-select picker and the dial-code sheet so the tag
|
|
13
|
+
* reads identically wherever the guess is offered.
|
|
14
|
+
*/
|
|
15
|
+
export function GeoBadge({ label }: { label: string }): React.ReactElement {
|
|
16
|
+
const { colors } = useTheme();
|
|
17
|
+
return (
|
|
18
|
+
<View
|
|
19
|
+
style={{
|
|
20
|
+
marginRight: spacing.sm,
|
|
21
|
+
paddingHorizontal: spacing.sm,
|
|
22
|
+
paddingVertical: 2,
|
|
23
|
+
borderRadius: radius.full,
|
|
24
|
+
borderWidth: 1,
|
|
25
|
+
borderColor: colors.primary,
|
|
26
|
+
backgroundColor: colors.primary50,
|
|
27
|
+
}}
|
|
28
|
+
>
|
|
29
|
+
<MyazaText variant="bodySmall" color={colors.primary} style={{ fontWeight: '500' }}>
|
|
30
|
+
{label}
|
|
31
|
+
</MyazaText>
|
|
32
|
+
</View>
|
|
33
|
+
);
|
|
34
|
+
}
|