@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
@@ -26,6 +26,7 @@ export function BusinessDetailsFields({
26
26
  formatOk,
27
27
  requireName,
28
28
  country,
29
+ geoCountry,
29
30
  modes,
30
31
  showCompanyInfo,
31
32
  showContactEmail,
@@ -38,6 +39,7 @@ export function BusinessDetailsFields({
38
39
  formatOk: boolean;
39
40
  requireName: boolean;
40
41
  country: string;
42
+ geoCountry?: string | null;
41
43
  modes: Record<CompanyInfoField, CompanyInfoMode>;
42
44
  showCompanyInfo: boolean;
43
45
  showContactEmail: boolean;
@@ -84,6 +86,7 @@ export function BusinessDetailsFields({
84
86
  values={business}
85
87
  modes={modes}
86
88
  country={country}
89
+ geoCountry={geoCountry}
87
90
  onChange={(field, value) => onChange(field, value)}
88
91
  />
89
92
  </>
@@ -45,6 +45,7 @@ export function BusinessDetailsStep(): React.ReactElement {
45
45
  const [phase, setPhase] = useState<'pick' | 'details'>('pick');
46
46
  // Production never shows the test-result toggle, and never honours a pin.
47
47
  const isSandbox = useKyc((s) => s.serverConfig.environment) !== 'PRODUCTION';
48
+ const geoCountry = useKyc((s) => s.serverConfig.geoCountry);
48
49
 
49
50
  const workflowBusiness = config.business;
50
51
  const countries = useMemo(() => businessCountriesFor(workflowBusiness), [workflowBusiness]);
@@ -171,6 +172,7 @@ export function BusinessDetailsStep(): React.ReactElement {
171
172
  formatOk={formatOk}
172
173
  requireName={nameRequired}
173
174
  country={country}
175
+ geoCountry={geoCountry}
174
176
  modes={modes}
175
177
  showCompanyInfo={showCompanyInfo}
176
178
  showContactEmail={showContactEmail}
@@ -2,6 +2,7 @@ import React from 'react';
2
2
  import { ActivityIndicator, Image, Pressable, View } from 'react-native';
3
3
 
4
4
  import { radius, spacing } from '../config/theme';
5
+ import { UPLOAD_HINT } from '../config/uploadLimits';
5
6
  import { useTheme } from '../components/runtime';
6
7
  import { MyazaText } from '../components/Typography';
7
8
  import { Icon } from '../components/Icon';
@@ -11,7 +12,7 @@ import { DashedBorder } from '../components/DashedBorder';
11
12
  // One business-document upload slot — mirrors the web SDK's
12
13
  // BusinessDocumentSlot 1:1: empty, it is a dashed full-width tap target
13
14
  // (upload glyph — a spinner while uploading — label with a red * when
14
- // required, "Photo or PDF, up to 20MB"); filled, a solid card showing a 48px
15
+ // required, the shared upload hint); filled, a solid card showing a 48px
15
16
  // THUMBNAIL of what was actually picked (a muted file tile for PDFs, a check
16
17
  // when the preview didn't survive a remount — web's exact fallback ladder),
17
18
  // the file's name over the slot label, Replace, and remove. Showing the file
@@ -158,7 +159,7 @@ export function BusinessDocumentSlot({
158
159
  ) : null}
159
160
  </MyazaText>
160
161
  <MyazaText variant="bodySmall" color={colors.textMuted}>
161
- {uploading ? 'Uploading…' : 'Photo or PDF, up to 20MB'}
162
+ {uploading ? 'Uploading…' : UPLOAD_HINT}
162
163
  </MyazaText>
163
164
  </View>
164
165
  </Pressable>
@@ -1,6 +1,5 @@
1
1
  import React, { useCallback, useMemo, useState } from 'react';
2
2
  import { View } from 'react-native';
3
- import * as ImagePicker from 'expo-image-picker';
4
3
 
5
4
  import { spacing } from '../config/theme';
6
5
  import { useKyc, useKycConfig, useKycStore, useTheme } from '../components/runtime';
@@ -11,8 +10,7 @@ import { BusinessDocumentSlot } from './BusinessDocumentSlot';
11
10
  import { withRetry } from '../services/retry';
12
11
  import { compressDocumentImage } from '../services/mediaCompress';
13
12
  import { resolveBusinessDocumentTypes, type ResolvedBusinessDocumentType } from '../config/businessSteps';
14
- import { isAcceptedPoaMimeType, POA_ACCEPTED_MIME_TYPES } from '../config/proofOfAddress';
15
- import { loadDocumentPicker } from '../services/documentPicker';
13
+ import { useBusinessDocumentAttach } from './useBusinessDocumentAttach';
16
14
 
17
15
  // ---------------------------------------------------------------------------
18
16
  // Supporting company documents (certificate of incorporation, MEMART, …).
@@ -26,7 +24,8 @@ import { loadDocumentPicker } from '../services/documentPicker';
26
24
  // Layout mirrors the web SDK 1:1: the header carries the description, each
27
25
  // document is a dashed upload slot (see BusinessDocumentSlot), and Continue is
28
26
  // disabled until every required slot is filled. Tapping a slot opens the
29
- // photo/file source sheet — the mobile stand-in for web's file input.
27
+ // photo/file source sheet — the mobile stand-in for web's file input; the
28
+ // pickers themselves live in useBusinessDocumentAttach.
30
29
  // ---------------------------------------------------------------------------
31
30
 
32
31
  export const businessDocumentsMeta = {
@@ -79,57 +78,7 @@ export function BusinessDocumentsStep(): React.ReactElement {
79
78
  [store],
80
79
  );
81
80
 
82
- const takePhoto = useCallback(
83
- async (slot: ResolvedBusinessDocumentType) => {
84
- const permission = await ImagePicker.requestCameraPermissionsAsync();
85
- if (!permission.granted) {
86
- setError('Camera access is needed to photograph the document.');
87
- return;
88
- }
89
- const result = await ImagePicker.launchCameraAsync({ mediaTypes: ['images'], quality: 1 });
90
- const asset = result.canceled ? undefined : result.assets[0];
91
- if (asset) {
92
- // A FRIENDLY name, not the picker's temp junk — camera/library assets
93
- // carry generated names; the slot key says what the file IS.
94
- await attach(slot, asset.uri, asset.mimeType ?? 'image/jpeg', `${slot.key}.jpg`);
95
- }
96
- },
97
- [attach],
98
- );
99
-
100
- const choosePhoto = useCallback(
101
- async (slot: ResolvedBusinessDocumentType) => {
102
- const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], quality: 1 });
103
- const asset = result.canceled ? undefined : result.assets[0];
104
- if (asset) {
105
- await attach(slot, asset.uri, asset.mimeType ?? 'image/jpeg', `${slot.key}.jpg`);
106
- }
107
- },
108
- [attach],
109
- );
110
-
111
- const chooseFile = useCallback(
112
- async (slot: ResolvedBusinessDocumentType) => {
113
- const picker = loadDocumentPicker();
114
- if (!picker) {
115
- setError('Choosing a file is not available in this app. Please photograph the document.');
116
- return;
117
- }
118
- const result = await picker.getDocumentAsync({
119
- type: [...POA_ACCEPTED_MIME_TYPES],
120
- copyToCacheDirectory: true,
121
- multiple: false,
122
- });
123
- const asset = result.canceled ? undefined : result.assets?.[0];
124
- if (!asset) return;
125
- if (!isAcceptedPoaMimeType(asset.mimeType)) {
126
- setError('Please choose a photo (JPEG, PNG or WebP) or a PDF.');
127
- return;
128
- }
129
- await attach(slot, asset.uri, asset.mimeType, asset.name ?? slot.key);
130
- },
131
- [attach],
132
- );
81
+ const { takePhoto, choosePhoto, chooseFile } = useBusinessDocumentAttach(attach, setError);
133
82
 
134
83
  const handleContinue = (): void => {
135
84
  if (missing.length > 0 || busySlot !== null) return;
@@ -45,12 +45,16 @@ export function CompanyInfoFields({
45
45
  values,
46
46
  modes,
47
47
  country,
48
+ geoCountry,
48
49
  onChange,
49
50
  }: {
50
51
  values: BusinessState;
51
52
  modes: Record<CompanyInfoField, CompanyInfoMode>;
52
53
  /** Seeds the phone dial code: the company's country of registration. */
53
54
  country?: string;
55
+ /** The visitor's IP country: the dial-code seed of last resort, and the
56
+ * row pinned to the top of the picker. */
57
+ geoCountry?: string | null;
54
58
  onChange: (field: CompanyInfoField, value: string) => void;
55
59
  }): React.ReactElement | null {
56
60
  const { colors } = useTheme();
@@ -88,7 +92,8 @@ export function CompanyInfoFields({
88
92
  // register's number when the lookup returned one.
89
93
  <PhoneNumberInput
90
94
  value={value}
91
- defaultCountry={country}
95
+ defaultCountry={country || geoCountry || undefined}
96
+ geoCountry={geoCountry}
92
97
  onChange={({ e164 }) => onChange('phone', e164)}
93
98
  />
94
99
  ) : f.kind === 'date' ? (
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
  import { Linking, Pressable, View } from 'react-native';
3
- import Svg, { Circle, Defs, LinearGradient, Stop } from 'react-native-svg';
3
+ import Svg, { Circle } from 'react-native-svg';
4
4
 
5
5
  import { radius, spacing } from '../config/theme';
6
6
  import { PRIVACY_URL, TERMS_URL } from '../config/brand';
@@ -81,21 +81,10 @@ export function ConsentStep(): React.ReactElement {
81
81
  elevation: 6,
82
82
  }}
83
83
  >
84
- {/* Top-left → bottom-right gradient badge, matching Flutter. A flat
85
- fill was the last visual difference on this screen. The second
86
- stop is the SAME primary at 70% opacity rather than a
87
- pre-blended colour: composited over the sheet it produces
88
- Flutter's `alphaBlend(primary@0.7, background)` exactly, and it
89
- keeps working when the consumer overrides primaryColor or the
90
- theme flips, which a hardcoded blend would not. */}
84
+ {/* Solid primary badge — no gradients in the flow (house rule
85
+ 2026-08-29, applied across all three SDKs in lockstep). */}
91
86
  <Svg width={56} height={56} style={{ position: 'absolute' }}>
92
- <Defs>
93
- <LinearGradient id="myazaShieldHero" x1="0" y1="0" x2="1" y2="1">
94
- <Stop offset="0" stopColor={colors.primary} stopOpacity={1} />
95
- <Stop offset="1" stopColor={colors.primary} stopOpacity={0.7} />
96
- </LinearGradient>
97
- </Defs>
98
- <Circle cx={28} cy={28} r={28} fill="url(#myazaShieldHero)" />
87
+ <Circle cx={28} cy={28} r={28} fill={colors.primary} />
99
88
  </Svg>
100
89
  <Icon name="shield" size={28} color={colors.onPrimary} />
101
90
  </View>
@@ -153,7 +142,7 @@ export function ConsentStep(): React.ReactElement {
153
142
  <MyazaText
154
143
  variant="bodySmall"
155
144
  color={colors.textDark}
156
- style={{ fontWeight: '600', textDecorationLine: 'underline' }}
145
+ style={{ fontWeight: '500', textDecorationLine: 'underline' }}
157
146
  onPress={() => void Linking.openURL(TERMS_URL).catch(() => undefined)}
158
147
  >
159
148
  End User Terms
@@ -162,7 +151,7 @@ export function ConsentStep(): React.ReactElement {
162
151
  <MyazaText
163
152
  variant="bodySmall"
164
153
  color={colors.textDark}
165
- style={{ fontWeight: '600', textDecorationLine: 'underline' }}
154
+ style={{ fontWeight: '500', textDecorationLine: 'underline' }}
166
155
  onPress={() => void Linking.openURL(PRIVACY_URL).catch(() => undefined)}
167
156
  >
168
157
  Privacy Policy
@@ -0,0 +1,54 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+
4
+ import { spacing } from '../config/theme';
5
+ import { MyazaButton } from '../components/MyazaButton';
6
+ import { ContactFooterNote } from './ContactFooterNote';
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // The contact step's footer: the one primary action (send, then verify), the
10
+ // optional skip, and the reassurance note. Split from ContactVerificationStep
11
+ // (200-line rule); the step owns every decision and this only renders them.
12
+ // ---------------------------------------------------------------------------
13
+
14
+ export function ContactActions({
15
+ hasChallenge,
16
+ busy,
17
+ disabled,
18
+ required,
19
+ isEmail,
20
+ onPrimary,
21
+ onSkip,
22
+ }: {
23
+ /** A code is out: the button verifies instead of sending. */
24
+ hasChallenge: boolean;
25
+ busy: boolean;
26
+ disabled: boolean;
27
+ /** `required: false` adds the skip; the server enforces the rest. */
28
+ required: boolean;
29
+ isEmail: boolean;
30
+ onPrimary: () => void;
31
+ onSkip: () => void;
32
+ }): React.ReactElement {
33
+ return (
34
+ <>
35
+ <View style={{ height: spacing.md }} />
36
+ <MyazaButton
37
+ label={hasChallenge ? 'Verify code' : 'Send code'}
38
+ loading={busy}
39
+ disabled={disabled}
40
+ onPress={onPrimary}
41
+ />
42
+
43
+ {!required ? (
44
+ <>
45
+ <View style={{ height: spacing.sm }} />
46
+ <MyazaButton label="Skip for now" variant="ghost" disabled={busy} onPress={onSkip} />
47
+ </>
48
+ ) : null}
49
+
50
+ <View style={{ height: spacing.md }} />
51
+ <ContactFooterNote isEmail={isEmail} />
52
+ </>
53
+ );
54
+ }
@@ -18,6 +18,7 @@ export function ContactDestinationField({
18
18
  onEmailChange,
19
19
  onPhoneChange,
20
20
  defaultCountry,
21
+ geoCountry,
21
22
  error,
22
23
  disabled,
23
24
  }: {
@@ -26,6 +27,8 @@ export function ContactDestinationField({
26
27
  onEmailChange: (value: string) => void;
27
28
  onPhoneChange: (value: { e164: string; isValid: boolean }) => void;
28
29
  defaultCountry?: string;
30
+ /** The visitor's IP country, pinned to the top of the dial-code picker. */
31
+ geoCountry?: string | null;
29
32
  error: string | null;
30
33
  disabled?: boolean;
31
34
  }): React.ReactElement {
@@ -52,6 +55,7 @@ export function ContactDestinationField({
52
55
  </MyazaText>
53
56
  <PhoneNumberInput
54
57
  defaultCountry={defaultCountry}
58
+ geoCountry={geoCountry}
55
59
  disabled={disabled}
56
60
  autoFocus
57
61
  onChange={onPhoneChange}
@@ -0,0 +1,75 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+
4
+ import { spacing } from '../config/theme';
5
+ import { MyazaAlert } from '../components/MyazaAlert';
6
+ import { ContactDestinationField } from './ContactDestinationField';
7
+ import { ContactChannelChoice } from './ContactChannelChoice';
8
+ import type { PhoneOtpChannel } from '../config/contact';
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // The first half of the contact step: where to send the code, and (when the
12
+ // workflow offers a choice) how. Composed as one widget so the step has a
13
+ // single child per phase. Split from ContactVerificationStep (200-line rule);
14
+ // mirrors Flutter's ContactEntryPanel.
15
+ // ---------------------------------------------------------------------------
16
+
17
+ export function ContactEntryPanel({
18
+ isEmail,
19
+ recovery,
20
+ email,
21
+ onEmailChange,
22
+ onPhoneChange,
23
+ defaultCountry,
24
+ geoCountry,
25
+ error,
26
+ disabled,
27
+ offeredChannels,
28
+ via,
29
+ onPickChannel,
30
+ }: {
31
+ isEmail: boolean;
32
+ /** Entered via submit recovery: the server refused this channel's proof. */
33
+ recovery: boolean;
34
+ email: string;
35
+ onEmailChange: (value: string) => void;
36
+ onPhoneChange: (value: { e164: string; isValid: boolean }) => void;
37
+ defaultCountry?: string;
38
+ geoCountry?: string | null;
39
+ error: string | null;
40
+ disabled: boolean;
41
+ offeredChannels: PhoneOtpChannel[];
42
+ via: PhoneOtpChannel;
43
+ onPickChannel: (channel: PhoneOtpChannel) => void;
44
+ }): React.ReactElement {
45
+ return (
46
+ <>
47
+ {recovery && (
48
+ <>
49
+ <MyazaAlert
50
+ variant="warning"
51
+ title="Please verify again"
52
+ message={`Your earlier confirmation has expired, so please verify ${isEmail ? 'your email' : 'your number'} once more. Everything else is saved, and we will submit again straight after.`}
53
+ />
54
+ <View style={{ height: spacing.md }} />
55
+ </>
56
+ )}
57
+ <ContactDestinationField
58
+ isEmail={isEmail}
59
+ email={email}
60
+ onEmailChange={onEmailChange}
61
+ onPhoneChange={onPhoneChange}
62
+ defaultCountry={defaultCountry}
63
+ geoCountry={geoCountry}
64
+ error={error}
65
+ disabled={disabled}
66
+ />
67
+ <ContactChannelChoice
68
+ offered={offeredChannels}
69
+ picked={via}
70
+ disabled={disabled}
71
+ onPick={onPickChannel}
72
+ />
73
+ </>
74
+ );
75
+ }
@@ -3,13 +3,10 @@ import { View } from 'react-native';
3
3
 
4
4
  import { spacing } from '../config/theme';
5
5
  import { useEffectiveCountry, useKyc, useKycConfig, useKycStore } from '../components/runtime';
6
- import { MyazaButton } from '../components/MyazaButton';
7
- import { MyazaAlert } from '../components/MyazaAlert';
8
- import { ContactDestinationField } from './ContactDestinationField';
6
+ import { ContactActions } from './ContactActions';
7
+ import { ContactEntryPanel } from './ContactEntryPanel';
9
8
  import { ContactVerifiedPanel } from './ContactVerifiedPanel';
10
9
  import { ContactCodeStep } from './ContactCodeStep';
11
- import { ContactChannelChoice } from './ContactChannelChoice';
12
- import { ContactFooterNote } from './ContactFooterNote';
13
10
  import {
14
11
  contactCodeLength,
15
12
  contactIsRequired,
@@ -57,10 +54,9 @@ export function ContactVerificationStep({
57
54
  const [destination, setDestination] = useState(contact.emailAddress ?? '');
58
55
  // The phone field emits E.164 + validity from libphonenumber, so the step no
59
56
  // longer guesses either from raw text.
60
- const [phone, setPhone] = useState<{ e164: string; isValid: boolean }>({
61
- e164: contact.phoneNumber ?? '',
62
- isValid: false,
63
- });
57
+ const [phone, setPhone] = useState<{ e164: string; isValid: boolean }>(
58
+ { e164: contact.phoneNumber ?? '', isValid: false },
59
+ );
64
60
  const [challengeId, setChallengeId] = useState<string | null>(null);
65
61
  const [expiresAt, setExpiresAt] = useState<string | null>(null);
66
62
  const [code, setCode] = useState('');
@@ -174,57 +170,31 @@ export function ContactVerificationStep({
174
170
  onResend={(switchTo) => void send(switchTo)}
175
171
  />
176
172
  ) : (
177
- <>
178
- {recovery && (
179
- <>
180
- <MyazaAlert
181
- variant="warning"
182
- title="Please verify again"
183
- message={`Your earlier confirmation has expired, so please verify ${isEmail ? 'your email' : 'your number'} once more. Everything else is saved, and we will submit again straight after.`}
184
- />
185
- <View style={{ height: spacing.md }} />
186
- </>
187
- )}
188
- <ContactDestinationField
189
- isEmail={isEmail}
190
- email={destination}
191
- onEmailChange={setDestination}
192
- onPhoneChange={setPhone}
193
- defaultCountry={
194
- config.phoneVerification?.defaultCountry ??
195
- country ??
196
- serverConfig.geoCountry ??
197
- undefined
198
- }
199
- error={error}
200
- disabled={busy}
201
- />
202
- <ContactChannelChoice
203
- offered={offeredChannels}
204
- picked={via}
205
- disabled={busy}
206
- onPick={setVia}
207
- />
208
- </>
173
+ <ContactEntryPanel
174
+ isEmail={isEmail}
175
+ recovery={recovery}
176
+ email={destination}
177
+ onEmailChange={setDestination}
178
+ onPhoneChange={setPhone}
179
+ defaultCountry={config.phoneVerification?.defaultCountry ?? country ?? serverConfig.geoCountry ?? undefined}
180
+ geoCountry={serverConfig.geoCountry}
181
+ error={error}
182
+ disabled={busy}
183
+ offeredChannels={offeredChannels}
184
+ via={via}
185
+ onPickChannel={setVia}
186
+ />
209
187
  )}
210
188
 
211
- <View style={{ height: spacing.md }} />
212
- <MyazaButton
213
- label={challengeId ? 'Verify code' : 'Send code'}
214
- loading={busy}
189
+ <ContactActions
190
+ hasChallenge={challengeId != null}
191
+ busy={busy}
215
192
  disabled={challengeId ? code.length !== codeLength : !canSend}
216
- onPress={() => (challengeId ? void check(code) : void send())}
193
+ required={required}
194
+ isEmail={isEmail}
195
+ onPrimary={() => (challengeId ? void check(code) : void send())}
196
+ onSkip={advance}
217
197
  />
218
-
219
- {!required ? (
220
- <>
221
- <View style={{ height: spacing.sm }} />
222
- <MyazaButton label="Skip for now" variant="ghost" disabled={busy} onPress={advance} />
223
- </>
224
- ) : null}
225
-
226
- <View style={{ height: spacing.md }} />
227
- <ContactFooterNote isEmail={isEmail} />
228
198
  </View>
229
199
  );
230
200
  }
@@ -1,13 +1,9 @@
1
- import React, { useMemo, useState } from 'react';
2
- import { Pressable, ScrollView, View } from 'react-native';
1
+ import React, { useMemo } from 'react';
2
+ import { View } from 'react-native';
3
3
 
4
- import { radius, spacing } from '../config/theme';
5
- import { useKyc, useKycConfig, useKycStore, useTheme } from '../components/runtime';
6
- import { MyazaText } from '../components/Typography';
7
- import { MyazaInput } from '../components/MyazaInput';
8
- import { CountryFlag } from '../components/CountryFlag';
9
- import { Icon } from '../components/Icon';
10
- import { groupCountriesByRegion, regionCountryName } from '../config/regions';
4
+ import { useKyc, useKycConfig, useKycStore } from '../components/runtime';
5
+ import { CountryRegionPicker, CountryRow } from '../components/CountryRegionPicker';
6
+ import { regionCountryName } from '../config/regions';
11
7
  import { countrySelectOptions } from '../store/derive';
12
8
 
13
9
  // ---------------------------------------------------------------------------
@@ -54,7 +50,14 @@ export function CountrySelectStep(): React.ReactElement {
54
50
  };
55
51
 
56
52
  if (options.length > COUNTRY_SEARCH_THRESHOLD) {
57
- return <CountryRegionPicker countries={options} selected={selected} onPick={pick} />;
53
+ return (
54
+ <CountryRegionPicker
55
+ countries={options}
56
+ selected={selected}
57
+ geoCountry={serverConfig.geoCountry}
58
+ onPick={pick}
59
+ />
60
+ );
58
61
  }
59
62
 
60
63
  return (
@@ -71,140 +74,3 @@ export function CountrySelectStep(): React.ReactElement {
71
74
  </View>
72
75
  );
73
76
  }
74
-
75
- function CountryRegionPicker({
76
- countries,
77
- selected,
78
- onPick,
79
- }: {
80
- countries: string[];
81
- selected: string | null;
82
- onPick: (country: string) => void;
83
- }): React.ReactElement {
84
- const { colors } = useTheme();
85
- const [query, setQuery] = useState('');
86
-
87
- const groups = useMemo(() => {
88
- const needle = query.trim().toLowerCase();
89
- const filtered = needle
90
- ? countries.filter(
91
- (code) =>
92
- code.toLowerCase().includes(needle) ||
93
- regionCountryName(code).toLowerCase().includes(needle),
94
- )
95
- : countries;
96
- return groupCountriesByRegion(filtered);
97
- }, [countries, query]);
98
-
99
- const empty = groups.length === 0;
100
-
101
- return (
102
- // The search box stays put and the list owns its own scroll, so a
103
- // ~240-country flow does not scroll the search field off the top. That
104
- // relies on the SHEET rendering this step with `fillsViewport` — inside a
105
- // scroll view the nested list below would take an unbounded height, outgrow
106
- // the viewport, and drag the search box off the top with it.
107
- <View style={{ flex: 1 }}>
108
- <MyazaInput
109
- value={query}
110
- onChangeText={setQuery}
111
- placeholder="Search countries…"
112
- autoCapitalize="none"
113
- prefix={<Icon name="search" size={18} color={colors.textSecondary} />}
114
- />
115
- <View style={{ height: spacing.md }} />
116
-
117
- {empty ? (
118
- <MyazaText variant="bodyMedium" style={{ textAlign: 'center', marginTop: spacing.lg }}>
119
- No countries match your search.
120
- </MyazaText>
121
- ) : (
122
- // `nestedScrollEnabled` stays: Android refuses to scroll a nested
123
- // same-direction list without it, and this is still nested on the
124
- // flat-list path and inside any future scrolling parent.
125
- <ScrollView
126
- nestedScrollEnabled
127
- keyboardShouldPersistTaps="handled"
128
- showsVerticalScrollIndicator={false}
129
- >
130
- {groups.map((group) => (
131
- <View key={group.region}>
132
- {/* Uppercase, tracked-out section label — Flutter's region
133
- header. Lowercase text at the default weight read as another
134
- list row rather than a divider. */}
135
- <MyazaText
136
- variant="bodySmall"
137
- color={colors.textSecondary}
138
- style={{
139
- fontWeight: '700',
140
- letterSpacing: 0.6,
141
- paddingHorizontal: spacing.xs,
142
- paddingVertical: spacing.sm,
143
- }}
144
- >
145
- {group.region.toUpperCase()}
146
- </MyazaText>
147
- {group.countries.map((entry) => (
148
- <CountryRow
149
- key={entry.code}
150
- code={entry.code}
151
- name={entry.name}
152
- selected={entry.code === selected}
153
- onPress={() => onPick(entry.code)}
154
- />
155
- ))}
156
- </View>
157
- ))}
158
- </ScrollView>
159
- )}
160
- </View>
161
- );
162
- }
163
-
164
- function CountryRow({
165
- code,
166
- name,
167
- selected,
168
- onPress,
169
- }: {
170
- code: string;
171
- name: string;
172
- selected: boolean;
173
- onPress: () => void;
174
- }): React.ReactElement {
175
- const { colors } = useTheme();
176
- return (
177
- <Pressable
178
- onPress={onPress}
179
- accessibilityRole="radio"
180
- accessibilityState={{ checked: selected }}
181
- accessibilityLabel={name}
182
- // Matches the Flutter SDK's CountryOptionTile exactly — the two had
183
- // drifted (24px flag / 12px padding here vs 32 / 16 there), which is why
184
- // the same screen looked like a different component on each platform. The
185
- // spacing and radius scales are identical across the SDKs, so there is
186
- // nothing platform-specific to reconcile: `md` means 16 on both.
187
- style={{
188
- flexDirection: 'row',
189
- alignItems: 'center',
190
- padding: spacing.md,
191
- borderRadius: radius.md,
192
- borderWidth: selected ? 1.5 : 1,
193
- borderColor: selected ? colors.primary : colors.border,
194
- backgroundColor: selected ? colors.primary50 : colors.background,
195
- marginBottom: spacing.sm,
196
- }}
197
- >
198
- <CountryFlag country={code} size={32} />
199
- <View style={{ width: 12 }} />
200
- <MyazaText variant="label" style={{ flex: 1, fontWeight: '500' }}>
201
- {name}
202
- </MyazaText>
203
- <Icon
204
- name="chevron-right"
205
- size={18}
206
- color={selected ? colors.primary : colors.textSecondary}
207
- />
208
- </Pressable>
209
- );
210
- }