@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.
Files changed (167) hide show
  1. package/README.md +151 -5
  2. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +6 -4
  3. package/app.plugin.js +89 -8
  4. package/ios/HybridMyazaFaceDetector.swift +8 -3
  5. package/package.json +14 -2
  6. package/src/MyazaBiometricAuth.tsx +119 -0
  7. package/src/components/CountryField.tsx +8 -0
  8. package/src/components/CountryRegionPicker.tsx +184 -0
  9. package/src/components/DialCodePicker.tsx +47 -68
  10. package/src/components/DialCodeRow.tsx +102 -0
  11. package/src/components/FramedMapPicker.tsx +122 -0
  12. package/src/components/GeoBadge.tsx +34 -0
  13. package/src/components/Icon.tsx +2 -185
  14. package/src/components/KycFlow.tsx +60 -32
  15. package/src/components/LineSkeleton.tsx +100 -0
  16. package/src/components/MapChrome.tsx +67 -0
  17. package/src/components/MapPinMarker.tsx +54 -0
  18. package/src/components/MapPinPicker.tsx +179 -0
  19. package/src/components/MilestoneTrack.tsx +155 -0
  20. package/src/components/MyazaInput.tsx +17 -1
  21. package/src/components/OptionRow.tsx +22 -3
  22. package/src/components/PhoneNumberInput.tsx +8 -0
  23. package/src/components/PoweredBy.tsx +5 -5
  24. package/src/components/PresenceBlocks.tsx +153 -0
  25. package/src/components/StepView.tsx +17 -0
  26. package/src/components/StickyActions.tsx +56 -0
  27. package/src/components/dialCodeRows.ts +64 -0
  28. package/src/components/icon-map.ts +176 -0
  29. package/src/components/stepHeaderMeta.tsx +42 -4
  30. package/src/config/addressCollection.ts +125 -0
  31. package/src/config/biometricOptions.ts +100 -0
  32. package/src/config/business.ts +20 -1
  33. package/src/config/businessSteps.ts +8 -2
  34. package/src/config/consentStep.ts +19 -0
  35. package/src/config/proofOfAddress.ts +54 -11
  36. package/src/config/regions.ts +24 -0
  37. package/src/config/stepOrder.ts +79 -5
  38. package/src/config/uploadLimits.ts +35 -0
  39. package/src/config/workflowMerge.ts +11 -5
  40. package/src/index.ts +54 -1
  41. package/src/lib/address-current-location.ts +171 -0
  42. package/src/lib/address-field-modes.ts +119 -0
  43. package/src/lib/address-flow.ts +172 -0
  44. package/src/lib/address-helpers.ts +48 -0
  45. package/src/lib/address-line.ts +76 -0
  46. package/src/lib/address-pin-move.ts +97 -0
  47. package/src/lib/address-step-recovery.ts +63 -0
  48. package/src/lib/authed-image.ts +78 -0
  49. package/src/lib/biometric-auth.ts +52 -0
  50. package/src/lib/biometric-copy.ts +51 -0
  51. package/src/lib/captureRing.ts +83 -0
  52. package/src/lib/country-adoption.ts +89 -0
  53. package/src/lib/inferred-country.ts +65 -0
  54. package/src/lib/livenessLayout.ts +49 -0
  55. package/src/lib/map-frame.ts +159 -0
  56. package/src/lib/map-tiles.ts +143 -0
  57. package/src/lib/poa-country-gate.ts +27 -0
  58. package/src/lib/result-copy.ts +116 -0
  59. package/src/lib/result-wait.ts +53 -0
  60. package/src/lib/review-map-surface.ts +26 -0
  61. package/src/lib/scope.ts +31 -0
  62. package/src/lib/selfie-upload-wait.ts +79 -0
  63. package/src/lib/street-view-fov.ts +42 -0
  64. package/src/lib/webview-available.ts +47 -0
  65. package/src/liveness/useLiveness.ts +6 -1
  66. package/src/presence/background-math.ts +100 -0
  67. package/src/presence/background-store.ts +82 -0
  68. package/src/presence/background.ts +165 -0
  69. package/src/presence/foreground-service.ts +193 -0
  70. package/src/presence/fs.ts +51 -0
  71. package/src/presence/geofence.ts +41 -0
  72. package/src/presence/math.ts +44 -0
  73. package/src/presence/post.ts +40 -0
  74. package/src/presence/report.ts +87 -0
  75. package/src/presence/sampler.ts +110 -0
  76. package/src/presence/status.ts +92 -0
  77. package/src/presence/store.ts +94 -0
  78. package/src/presence/tier.ts +36 -0
  79. package/src/presence/watch-wait.ts +81 -0
  80. package/src/screens/AddressCountryControl.tsx +115 -0
  81. package/src/screens/BusinessDetailsFields.tsx +3 -0
  82. package/src/screens/BusinessDetailsStep.tsx +2 -0
  83. package/src/screens/BusinessDocumentSlot.tsx +3 -2
  84. package/src/screens/BusinessDocumentsStep.tsx +4 -55
  85. package/src/screens/CompanyInfoFields.tsx +6 -1
  86. package/src/screens/ConsentStep.tsx +6 -17
  87. package/src/screens/ContactActions.tsx +54 -0
  88. package/src/screens/ContactDestinationField.tsx +4 -0
  89. package/src/screens/ContactEntryPanel.tsx +75 -0
  90. package/src/screens/ContactVerificationStep.tsx +26 -56
  91. package/src/screens/CountrySelectStep.tsx +13 -147
  92. package/src/screens/LivenessAvatar.tsx +15 -3
  93. package/src/screens/LivenessStep.tsx +154 -52
  94. package/src/screens/PoaDocumentTypeList.tsx +66 -0
  95. package/src/screens/ProofOfAddressParts.tsx +151 -0
  96. package/src/screens/ProofOfAddressStep.tsx +58 -99
  97. package/src/screens/SubmittedBadge.tsx +25 -0
  98. package/src/screens/SubmittedError.tsx +64 -0
  99. package/src/screens/SubmittedResult.tsx +117 -0
  100. package/src/screens/SubmittedStep.tsx +71 -164
  101. package/src/screens/SubmittedSuccess.tsx +127 -0
  102. package/src/screens/SubmittedWaiting.tsx +45 -0
  103. package/src/screens/address/AddressEntranceStep.tsx +161 -0
  104. package/src/screens/address/AddressIntroGate.tsx +148 -0
  105. package/src/screens/address/AddressMapStub.tsx +50 -0
  106. package/src/screens/address/AddressPinStep.tsx +178 -0
  107. package/src/screens/address/AddressReviewStep.tsx +162 -0
  108. package/src/screens/address/AddressSandboxTabs.tsx +158 -0
  109. package/src/screens/address/AddressSearchField.tsx +115 -0
  110. package/src/screens/address/AddressSearchStep.tsx +94 -0
  111. package/src/screens/address/CurrentLocationRow.tsx +135 -0
  112. package/src/screens/address/DetailsSheet.tsx +175 -0
  113. package/src/screens/address/DetailsSheetFields.tsx +184 -0
  114. package/src/screens/address/EntranceDropzone.tsx +178 -0
  115. package/src/screens/address/EntranceFraming.tsx +66 -0
  116. package/src/screens/address/EntrancePills.tsx +60 -0
  117. package/src/screens/address/FramedStreetView.tsx +155 -0
  118. package/src/screens/address/IntroDisclosures.tsx +210 -0
  119. package/src/screens/address/LabelDecisionRow.tsx +113 -0
  120. package/src/screens/address/PinSummaryRow.tsx +105 -0
  121. package/src/screens/address/ReviewAddressBand.tsx +111 -0
  122. package/src/screens/address/ReviewEntranceThumbs.tsx +64 -0
  123. package/src/screens/address/ReviewMapPicture.tsx +106 -0
  124. package/src/screens/address/SearchResults.tsx +105 -0
  125. package/src/screens/address/SearchScreen.tsx +176 -0
  126. package/src/screens/address/SkipForNow.tsx +43 -0
  127. package/src/screens/address/StreetViewChrome.tsx +81 -0
  128. package/src/screens/address/detail-values.ts +22 -0
  129. package/src/screens/address/fix-source.ts +27 -0
  130. package/src/screens/address/index.ts +8 -0
  131. package/src/screens/address/meta.ts +44 -0
  132. package/src/screens/address/use-address-flow.ts +200 -0
  133. package/src/screens/address/use-label-pin.ts +80 -0
  134. package/src/screens/address/use-pin-actions.ts +192 -0
  135. package/src/screens/biometric/BiometricAuthFlow.tsx +170 -0
  136. package/src/screens/consent/model.ts +79 -10
  137. package/src/screens/liveness/CaptureRing.tsx +91 -0
  138. package/src/screens/liveness/LivenessCamera.tsx +63 -0
  139. package/src/screens/liveness/LivenessHandover.tsx +43 -0
  140. package/src/screens/liveness/LivenessOutcome.tsx +4 -3
  141. package/src/screens/liveness/SelfiePreview.tsx +13 -4
  142. package/src/screens/liveness/ShutterFlash.tsx +25 -0
  143. package/src/screens/liveness/index.ts +4 -0
  144. package/src/screens/liveness/useSelfieUpload.ts +23 -3
  145. package/src/screens/nfc/NfcScanIllustration.tsx +7 -18
  146. package/src/screens/useAddressPhotoAttach.ts +77 -0
  147. package/src/screens/useBusinessDocumentAttach.ts +93 -0
  148. package/src/screens/usePoaAttach.ts +12 -15
  149. package/src/services/api-biometric.ts +37 -0
  150. package/src/services/api-types-biometric.ts +41 -0
  151. package/src/services/api-types.ts +93 -1
  152. package/src/services/api-verify-types.ts +37 -0
  153. package/src/services/api.ts +133 -3
  154. package/src/services/deviceMetadata.ts +1 -1
  155. package/src/services/location.ts +234 -0
  156. package/src/services/uploadErrors.ts +30 -0
  157. package/src/services/workflowGate.ts +7 -0
  158. package/src/store/address.ts +97 -0
  159. package/src/store/derive.ts +29 -4
  160. package/src/store/kycStore.ts +112 -3
  161. package/src/store/serverConfig.ts +18 -0
  162. package/src/store/session.ts +23 -4
  163. package/src/store/state.ts +134 -0
  164. package/src/store/submit.ts +18 -1
  165. package/src/types/config.ts +48 -1
  166. package/src/types/verification.ts +12 -0
  167. 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
- const filtered = useMemo(() => {
82
- const q = query.trim().toLowerCase();
83
- if (!q) return options;
84
- // Match the name, the ISO code, or the dial code with or without its '+',
85
- // because people search for "+234", "234" and "Nigeria" in equal measure.
86
- const bare = q.replace(/^\+/, '');
87
- return options.filter(
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={filtered}
125
- keyExtractor={(o) => o.code}
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
- <CountryRow
134
- option={item}
135
- isSelected={item.code === selected}
136
- onPress={() => {
137
- setQuery('');
138
- onPick(item.code);
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
+ }