Skip to content

Commit 0072bc2

Browse files
authored
feat(deposit): allow phone region to differ from KYC region (#21002)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** This PR introduces the ability for a user to select a phone country that is different from the country used for KYC, including ones that are not explicitly supported for the deposit feature. Previously, the phone country determined the user's KYC country. <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: Enabled phone country selection independent of KYC country. ## **Related issues** https://consensyssoftware.atlassian.net/browse/TRAM-2560 Fixes: ## **Manual testing steps** ```gherkin Feature: DepositPhone Input Selection Scenario: user enters the deposit KYC flow Given the user has selected USA When user changes their phone input region Then user should be allowed to choose a different region And the user should be allowed to choose an unsupported region (such as Canada) And when the user is sent to the address page Then the user should see the original region (USA) ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> https://github.com/user-attachments/assets/85c53681-9619-4898-9e7c-e6e3e293d43d ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Enables selecting a phone country independently of the global KYC region by adding flags to `RegionSelectorModal` and updating `DepositPhoneField` to manage its own phone region. > > - **DepositPhoneField**: > - Use local `phoneRegion` state for display/formatting; fall back to defaults when missing. > - Open `RegionSelectorModal` with params: `onRegionSelect`, `selectedRegion`, `allRegionsSelectable: true`, `updateGlobalRegion: false`, `trackSelection: false`. > - Tests: verify navigation params and that selecting a region updates the phone field. > - **RegionSelectorModal**: > - Add navigation params: `onRegionSelect`, `selectedRegion`, `allRegionsSelectable`, `updateGlobalRegion`, `trackSelection`; prefer `selectedRegion` param over SDK value. > - Allow selecting unsupported regions when `allRegionsSelectable` is true; optionally skip global region updates and analytics based on flags. > - Item state uses `isSelectable`; refactor selection handling and visual states. > - Tests and snapshots updated for new behaviors and edge cases. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 63e6cc2. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 9cdf292 commit 0072bc2

File tree

6 files changed

+2745
-67
lines changed

6 files changed

+2745
-67
lines changed

app/components/UI/Ramp/Deposit/Views/Modals/RegionSelectorModal/RegionSelectorModal.test.tsx

Lines changed: 104 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ describe('RegionSelectorModal Component', () => {
6262

6363
mockUseParams.mockReturnValue({
6464
regions: mockRegions,
65-
error: null,
65+
onRegionSelect: undefined,
6666
});
6767

6868
mockTrackEvent.mockClear();
@@ -151,7 +151,7 @@ describe('RegionSelectorModal Component', () => {
151151

152152
mockUseParams.mockReturnValue({
153153
regions: customRegions,
154-
error: null,
154+
onRegionSelect: undefined,
155155
});
156156

157157
const { toJSON } = renderWithProvider(RegionSelectorModal);
@@ -162,11 +162,112 @@ describe('RegionSelectorModal Component', () => {
162162
it('handles empty regions array from navigation params', () => {
163163
mockUseParams.mockReturnValue({
164164
regions: [],
165-
error: null,
166165
});
167166

168167
const { toJSON } = renderWithProvider(RegionSelectorModal);
169168

170169
expect(toJSON()).toMatchSnapshot();
171170
});
171+
172+
it('calls onRegionSelect callback when provided and region is selected', () => {
173+
const mockOnRegionSelect = jest.fn();
174+
175+
mockUseParams.mockReturnValue({
176+
regions: mockRegions,
177+
onRegionSelect: mockOnRegionSelect,
178+
});
179+
180+
const { getByText } = renderWithProvider(RegionSelectorModal);
181+
const germanyRegion = getByText('Germany');
182+
183+
fireEvent.press(germanyRegion);
184+
185+
expect(mockOnRegionSelect).toHaveBeenCalledWith(
186+
expect.objectContaining({
187+
isoCode: 'DE',
188+
name: 'Germany',
189+
supported: true,
190+
}),
191+
);
192+
});
193+
194+
it('does not update global region when updateGlobalRegion is false', () => {
195+
mockUseParams.mockReturnValue({
196+
regions: mockRegions,
197+
onRegionSelect: undefined,
198+
updateGlobalRegion: false,
199+
});
200+
201+
const { getByText } = renderWithProvider(RegionSelectorModal);
202+
const germanyRegion = getByText('Germany');
203+
204+
fireEvent.press(germanyRegion);
205+
206+
expect(mockSetSelectedRegion).not.toHaveBeenCalled();
207+
});
208+
209+
it('does not track analytics when trackSelection is false', () => {
210+
mockUseParams.mockReturnValue({
211+
regions: mockRegions,
212+
onRegionSelect: undefined,
213+
trackSelection: false,
214+
});
215+
216+
const { getByText } = renderWithProvider(RegionSelectorModal);
217+
const germanyRegion = getByText('Germany');
218+
219+
fireEvent.press(germanyRegion);
220+
221+
expect(mockTrackEvent).not.toHaveBeenCalled();
222+
});
223+
224+
it('render matches snapshot with allRegionsSelectable set to true', () => {
225+
mockUseParams.mockReturnValue({
226+
regions: mockRegions,
227+
onRegionSelect: undefined,
228+
allRegionsSelectable: true,
229+
});
230+
231+
const { toJSON } = renderWithProvider(RegionSelectorModal);
232+
233+
expect(toJSON()).toMatchSnapshot();
234+
});
235+
236+
it('render matches snapshot with custom selectedRegion', () => {
237+
const germanyRegion = mockRegions.find((r) => r.isoCode === 'DE');
238+
239+
mockUseParams.mockReturnValue({
240+
regions: mockRegions,
241+
onRegionSelect: undefined,
242+
selectedRegion: germanyRegion,
243+
});
244+
245+
const { toJSON } = renderWithProvider(RegionSelectorModal);
246+
247+
expect(toJSON()).toMatchSnapshot();
248+
});
249+
250+
it('allows selection of unsupported regions when allRegionsSelectable is true', () => {
251+
const mockOnRegionSelect = jest.fn();
252+
253+
mockUseParams.mockReturnValue({
254+
regions: mockRegions,
255+
onRegionSelect: mockOnRegionSelect,
256+
allRegionsSelectable: true,
257+
});
258+
259+
const { getByText } = renderWithProvider(RegionSelectorModal);
260+
const canadaRegion = getByText('Canada');
261+
262+
fireEvent.press(canadaRegion);
263+
264+
expect(mockOnRegionSelect).toHaveBeenCalledWith(
265+
expect.objectContaining({
266+
isoCode: 'CA',
267+
name: 'Canada',
268+
supported: false,
269+
}),
270+
);
271+
expect(mockSetSelectedRegion).toHaveBeenCalled();
272+
});
172273
});

app/components/UI/Ramp/Deposit/Views/Modals/RegionSelectorModal/RegionSelectorModal.tsx

Lines changed: 94 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ const MAX_REGION_RESULTS = 20;
3333

3434
interface RegionSelectorModalParams {
3535
regions: DepositRegion[];
36+
onRegionSelect?: (region: DepositRegion) => void;
37+
selectedRegion?: DepositRegion | null;
38+
allRegionsSelectable?: boolean;
39+
updateGlobalRegion?: boolean;
40+
trackSelection?: boolean;
3641
}
3742

3843
export const createRegionSelectorModalNavigationDetails =
@@ -45,9 +50,21 @@ function RegionSelectorModal() {
4550
const sheetRef = useRef<BottomSheetRef>(null);
4651
const listRef = useRef<FlatList<DepositRegion>>(null);
4752

48-
const { selectedRegion, setSelectedRegion, isAuthenticated } =
49-
useDepositSDK();
50-
const { regions } = useParams<RegionSelectorModalParams>();
53+
const {
54+
selectedRegion: sdkSelectedRegion,
55+
setSelectedRegion,
56+
isAuthenticated,
57+
} = useDepositSDK();
58+
const {
59+
regions,
60+
onRegionSelect,
61+
selectedRegion: selectedRegionParam,
62+
allRegionsSelectable = false,
63+
updateGlobalRegion = true,
64+
trackSelection = true,
65+
} = useParams<RegionSelectorModalParams>();
66+
67+
const selectedRegion = selectedRegionParam ?? sdkSelectedRegion;
5168
const [searchString, setSearchString] = useState('');
5269
const { height: screenHeight } = useWindowDimensions();
5370
const { styles } = useStyles(styleSheet, {
@@ -97,60 +114,88 @@ function RegionSelectorModal() {
97114

98115
const handleOnRegionPressCallback = useCallback(
99116
(region: DepositRegion) => {
100-
if (region.supported && setSelectedRegion) {
101-
trackEvent('RAMPS_REGION_SELECTED', {
102-
ramp_type: 'DEPOSIT',
103-
region: region.isoCode,
104-
is_authenticated: isAuthenticated,
105-
});
106-
107-
setSelectedRegion(region);
117+
const isSelectable = allRegionsSelectable || region.supported;
118+
119+
if (isSelectable) {
120+
if (onRegionSelect) {
121+
onRegionSelect(region);
122+
}
123+
124+
if (updateGlobalRegion) {
125+
setSelectedRegion(region);
126+
}
127+
128+
if (trackSelection) {
129+
trackEvent('RAMPS_REGION_SELECTED', {
130+
ramp_type: 'DEPOSIT',
131+
region: region.isoCode,
132+
is_authenticated: isAuthenticated,
133+
});
134+
}
135+
108136
sheetRef.current?.onCloseBottomSheet();
109137
}
110138
},
111-
[setSelectedRegion, isAuthenticated, trackEvent],
139+
[
140+
onRegionSelect,
141+
allRegionsSelectable,
142+
updateGlobalRegion,
143+
trackSelection,
144+
trackEvent,
145+
isAuthenticated,
146+
setSelectedRegion,
147+
],
112148
);
113149

114150
const renderRegionItem = useCallback(
115-
({ item: region }: { item: DepositRegion }) => (
116-
<ListItemSelect
117-
isSelected={selectedRegion?.isoCode === region.isoCode}
118-
onPress={() => {
119-
if (region.supported) {
151+
({ item: region }: { item: DepositRegion }) => {
152+
const isSelected = region.isoCode === selectedRegion?.isoCode;
153+
const isSelectable = allRegionsSelectable || region.supported;
154+
155+
return (
156+
<ListItemSelect
157+
isSelected={isSelected}
158+
onPress={() => {
120159
handleOnRegionPressCallback(region);
121-
}
122-
}}
123-
accessibilityRole="button"
124-
accessible
125-
disabled={!region.supported}
126-
>
127-
<ListItemColumn widthType={WidthType.Fill}>
128-
<View style={styles.region}>
129-
<View style={styles.emoji}>
130-
<Text
131-
variant={TextVariant.BodyLGMedium}
132-
color={
133-
region.supported ? TextColor.Default : TextColor.Alternative
134-
}
135-
>
136-
{region.flag}
137-
</Text>
138-
</View>
139-
<View>
140-
<Text
141-
variant={TextVariant.BodyLGMedium}
142-
color={
143-
region.supported ? TextColor.Default : TextColor.Alternative
144-
}
145-
>
146-
{region.name}
147-
</Text>
160+
}}
161+
accessibilityRole="button"
162+
accessible
163+
disabled={!isSelectable}
164+
>
165+
<ListItemColumn widthType={WidthType.Fill}>
166+
<View style={styles.region}>
167+
<View style={styles.emoji}>
168+
<Text
169+
variant={TextVariant.BodyLGMedium}
170+
color={
171+
isSelectable ? TextColor.Default : TextColor.Alternative
172+
}
173+
>
174+
{region.flag}
175+
</Text>
176+
</View>
177+
<View>
178+
<Text
179+
variant={TextVariant.BodyLGMedium}
180+
color={
181+
isSelectable ? TextColor.Default : TextColor.Alternative
182+
}
183+
>
184+
{region.name}
185+
</Text>
186+
</View>
148187
</View>
149-
</View>
150-
</ListItemColumn>
151-
</ListItemSelect>
152-
),
153-
[handleOnRegionPressCallback, selectedRegion, styles.region, styles.emoji],
188+
</ListItemColumn>
189+
</ListItemSelect>
190+
);
191+
},
192+
[
193+
selectedRegion?.isoCode,
194+
allRegionsSelectable,
195+
handleOnRegionPressCallback,
196+
styles.region,
197+
styles.emoji,
198+
],
154199
);
155200

156201
const renderEmptyList = useCallback(

0 commit comments

Comments
 (0)