diff --git a/app/components/UI/Assets/watchlist/Views/WatchlistFullScreenView/WatchlistFullScreenView.styles.ts b/app/components/UI/Assets/watchlist/Views/WatchlistFullScreenView/WatchlistFullScreenView.styles.ts
index 38d6c10436a3..e3e349f8478b 100644
--- a/app/components/UI/Assets/watchlist/Views/WatchlistFullScreenView/WatchlistFullScreenView.styles.ts
+++ b/app/components/UI/Assets/watchlist/Views/WatchlistFullScreenView/WatchlistFullScreenView.styles.ts
@@ -24,6 +24,9 @@ const styleSheet = (params: { theme: Theme }) => {
paddingHorizontal: 16,
paddingTop: 8,
},
+ emptyContentContainer: {
+ flex: 1,
+ },
editableRow: {
flexDirection: 'row',
alignItems: 'center',
diff --git a/app/components/UI/Assets/watchlist/Views/WatchlistFullScreenView/WatchlistFullScreenView.test.tsx b/app/components/UI/Assets/watchlist/Views/WatchlistFullScreenView/WatchlistFullScreenView.test.tsx
index 851c058169d0..a14de88f3758 100644
--- a/app/components/UI/Assets/watchlist/Views/WatchlistFullScreenView/WatchlistFullScreenView.test.tsx
+++ b/app/components/UI/Assets/watchlist/Views/WatchlistFullScreenView/WatchlistFullScreenView.test.tsx
@@ -24,6 +24,15 @@ jest.mock('react-native-reanimated', () => {
return Reanimated;
});
+jest.mock('../../components/WatchlistEmptyCTA', () => {
+ const { View } = jest.requireActual('react-native');
+ const ReactActual = jest.requireActual('react');
+ const Mock = () =>
+ ReactActual.createElement(View, { testID: 'watchlist-empty-cta' });
+ Mock.displayName = 'WatchlistEmptyCTA';
+ return Mock;
+});
+
jest.mock('./WatchlistEditableRow', () => {
const { Text, View } = jest.requireActual('react-native');
const ReactActual = jest.requireActual('react');
@@ -130,9 +139,10 @@ describe('WatchlistFullScreenView', () => {
expect(getByTestId('editable-row-eip155:1/erc20:0xeth')).toBeDefined();
});
- it('omits the token list when the watchlist is empty', () => {
- const { queryByTestId } = render();
+ it('renders empty CTA when the watchlist is empty', () => {
+ const { getByTestId, queryByTestId } = render();
+ expect(getByTestId('watchlist-empty-cta')).toBeDefined();
expect(
queryByTestId(WatchlistFullScreenViewSelectorsIDs.TOKEN_LIST),
).toBeNull();
diff --git a/app/components/UI/Assets/watchlist/Views/WatchlistFullScreenView/WatchlistFullScreenView.tsx b/app/components/UI/Assets/watchlist/Views/WatchlistFullScreenView/WatchlistFullScreenView.tsx
index 959a15124b4a..c81b2e7ce2f2 100644
--- a/app/components/UI/Assets/watchlist/Views/WatchlistFullScreenView/WatchlistFullScreenView.tsx
+++ b/app/components/UI/Assets/watchlist/Views/WatchlistFullScreenView/WatchlistFullScreenView.tsx
@@ -21,6 +21,7 @@ import TrendingTokensSkeleton from '../../../../Trending/components/TrendingToke
import { strings } from '../../../../../../../locales/i18n';
import { WatchlistFullScreenViewSelectorsIDs } from './WatchlistFullScreenView.testIds';
import WatchlistEditableRow from './WatchlistEditableRow';
+import WatchlistEmptyCTA from '../../components/WatchlistEmptyCTA';
import styleSheet from './WatchlistFullScreenView.styles';
const SKELETON_COUNT = 5;
@@ -119,7 +120,11 @@ const WatchlistFullScreenView = () => {
}
if (!hasItems) {
- return null;
+ return (
+
+
+
+ );
}
return (
@@ -145,7 +150,14 @@ const WatchlistFullScreenView = () => {
);
- }, [displayTokens, hasItems, isEditMode, isLoading, styles.listContainer]);
+ }, [
+ displayTokens,
+ hasItems,
+ isEditMode,
+ isLoading,
+ styles.emptyContentContainer,
+ styles.listContainer,
+ ]);
return (
{
+ const { theme, vars } = params;
+ const { isSelected } = vars;
+
+ return StyleSheet.create({
+ card: {
+ flex: 1,
+ flexDirection: 'column',
+ justifyContent: 'center',
+ alignItems: 'flex-start',
+ gap: 12,
+ padding: 16,
+ borderRadius: 12,
+ borderWidth: 1,
+ borderColor: isSelected
+ ? theme.colors.border.default
+ : theme.colors.border.muted,
+ backgroundColor: theme.colors.background.section,
+ },
+ topRow: {
+ width: '100%',
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ justifyContent: 'space-between',
+ },
+ logoContainer: {
+ flexShrink: 0,
+ },
+ checkboxContainer: {
+ flexShrink: 0,
+ },
+ });
+};
+
+export default styleSheet;
diff --git a/app/components/UI/Assets/watchlist/components/WatchlistDefaultTokenCard/WatchlistDefaultTokenCard.test.tsx b/app/components/UI/Assets/watchlist/components/WatchlistDefaultTokenCard/WatchlistDefaultTokenCard.test.tsx
new file mode 100644
index 000000000000..d6f65d822a84
--- /dev/null
+++ b/app/components/UI/Assets/watchlist/components/WatchlistDefaultTokenCard/WatchlistDefaultTokenCard.test.tsx
@@ -0,0 +1,94 @@
+import React from 'react';
+import { fireEvent, render } from '@testing-library/react-native';
+import WatchlistDefaultTokenCard from './WatchlistDefaultTokenCard';
+import {
+ getWatchlistDefaultTokenCardTestId,
+ WatchlistDefaultTokenCardTestIds,
+} from './WatchlistDefaultTokenCard.testIds';
+import type { WatchlistTokenWithBalance } from '../../utils/addBalanceToTokens';
+
+jest.mock('../../../../Trending/components/TrendingTokenLogo', () => {
+ const { View } = jest.requireActual('react-native');
+ const ReactActual = jest.requireActual('react');
+ const Mock = ({ testID }: { testID?: string }) =>
+ ReactActual.createElement(View, { testID: testID ?? 'token-logo' });
+ Mock.displayName = 'TrendingTokenLogo';
+ return Mock;
+});
+
+const makeToken = (
+ overrides: Partial = {},
+): WatchlistTokenWithBalance => ({
+ assetId: 'eip155:1/slip44:60',
+ symbol: 'ETH',
+ name: 'Ethereum',
+ decimals: 18,
+ balance: '0',
+ isInWallet: false,
+ marketData: {
+ price: 3000,
+ pricePercentChange24h: 4.2,
+ },
+ ...overrides,
+});
+
+describe('WatchlistDefaultTokenCard', () => {
+ it('renders symbol and price change', () => {
+ const token = makeToken();
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(
+ getByTestId(`${WatchlistDefaultTokenCardTestIds.SYMBOL}-${token.assetId}`)
+ .props.children,
+ ).toBe('ETH');
+ expect(
+ getByTestId(
+ `${WatchlistDefaultTokenCardTestIds.PRICE_CHANGE}-${token.assetId}`,
+ ).props.children,
+ ).toBe('+4.20%');
+ });
+
+ it('calls onToggle when the card is pressed', () => {
+ const onToggle = jest.fn();
+ const token = makeToken();
+ const { getByTestId } = render(
+ ,
+ );
+
+ fireEvent.press(
+ getByTestId(getWatchlistDefaultTokenCardTestId(token.assetId)),
+ );
+
+ expect(onToggle).toHaveBeenCalledWith(String(token.assetId));
+ });
+
+ it('calls onToggle when the checkbox is pressed', () => {
+ const onToggle = jest.fn();
+ const token = makeToken();
+ const { getByTestId } = render(
+ ,
+ );
+
+ fireEvent.press(
+ getByTestId(
+ `${WatchlistDefaultTokenCardTestIds.CHECKBOX}-${token.assetId}`,
+ ),
+ );
+
+ expect(onToggle).toHaveBeenCalledWith(String(token.assetId));
+ });
+});
diff --git a/app/components/UI/Assets/watchlist/components/WatchlistDefaultTokenCard/WatchlistDefaultTokenCard.testIds.ts b/app/components/UI/Assets/watchlist/components/WatchlistDefaultTokenCard/WatchlistDefaultTokenCard.testIds.ts
new file mode 100644
index 000000000000..7c4d485e7f2c
--- /dev/null
+++ b/app/components/UI/Assets/watchlist/components/WatchlistDefaultTokenCard/WatchlistDefaultTokenCard.testIds.ts
@@ -0,0 +1,9 @@
+export enum WatchlistDefaultTokenCardTestIds {
+ CARD = 'watchlist-default-token-card',
+ CHECKBOX = 'watchlist-default-token-card-checkbox',
+ SYMBOL = 'watchlist-default-token-card-symbol',
+ PRICE_CHANGE = 'watchlist-default-token-card-price-change',
+}
+
+export const getWatchlistDefaultTokenCardTestId = (assetId: string): string =>
+ `${WatchlistDefaultTokenCardTestIds.CARD}-${assetId}`;
diff --git a/app/components/UI/Assets/watchlist/components/WatchlistDefaultTokenCard/WatchlistDefaultTokenCard.tsx b/app/components/UI/Assets/watchlist/components/WatchlistDefaultTokenCard/WatchlistDefaultTokenCard.tsx
new file mode 100644
index 000000000000..76ad38d7c91f
--- /dev/null
+++ b/app/components/UI/Assets/watchlist/components/WatchlistDefaultTokenCard/WatchlistDefaultTokenCard.tsx
@@ -0,0 +1,119 @@
+import React, { useCallback, useMemo } from 'react';
+import { Pressable, View } from 'react-native';
+import {
+ BadgeNetwork,
+ BadgeWrapper,
+ BadgeWrapperPosition,
+ Checkbox,
+ Text,
+ TextVariant,
+ FontWeight,
+} from '@metamask/design-system-react-native';
+import { useStyles } from '../../../../../../component-library/hooks';
+import TrendingTokenLogo from '../../../../Trending/components/TrendingTokenLogo';
+import {
+ getCaipChainIdFromAssetId,
+ getNetworkBadgeSource,
+} from '../../../../Trending/components/TrendingTokenRowItem/utils';
+import { formatPercentChange } from '../../../../Trending/utils/formatPercentChange';
+import type { WatchlistTokenWithBalance } from '../../utils/addBalanceToTokens';
+import styleSheet from './WatchlistDefaultTokenCard.styles';
+import {
+ getWatchlistDefaultTokenCardTestId,
+ WatchlistDefaultTokenCardTestIds,
+} from './WatchlistDefaultTokenCard.testIds';
+
+interface WatchlistDefaultTokenCardProps {
+ token: WatchlistTokenWithBalance;
+ isSelected: boolean;
+ onToggle: (assetId: string) => void;
+}
+
+const WatchlistDefaultTokenCard: React.FC = ({
+ token,
+ isSelected,
+ onToggle,
+}) => {
+ const assetId = String(token.assetId);
+ const { styles } = useStyles(styleSheet, { isSelected });
+
+ const networkBadgeSource = useMemo(
+ () => getNetworkBadgeSource(getCaipChainIdFromAssetId(assetId)),
+ [assetId],
+ );
+
+ const { changeLabel, changeTextColor } = useMemo(
+ () => formatPercentChange(token.marketData?.pricePercentChange24h),
+ [token.marketData?.pricePercentChange24h],
+ );
+
+ const handlePress = useCallback(() => {
+ onToggle(assetId);
+ }, [assetId, onToggle]);
+
+ const handleCheckboxChange = useCallback(() => {
+ onToggle(assetId);
+ }, [assetId, onToggle]);
+
+ return (
+
+
+
+ ['src']
+ }
+ />
+ ) : null
+ }
+ >
+
+
+
+
+
+
+
+
+ {token.symbol}
+
+ {changeLabel ? (
+
+ {changeLabel}
+
+ ) : null}
+
+ );
+};
+
+export default WatchlistDefaultTokenCard;
diff --git a/app/components/UI/Assets/watchlist/components/WatchlistDefaultTokenCard/index.ts b/app/components/UI/Assets/watchlist/components/WatchlistDefaultTokenCard/index.ts
new file mode 100644
index 000000000000..c4372476e5a4
--- /dev/null
+++ b/app/components/UI/Assets/watchlist/components/WatchlistDefaultTokenCard/index.ts
@@ -0,0 +1 @@
+export { default } from './WatchlistDefaultTokenCard';
diff --git a/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.styles.ts b/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.styles.ts
new file mode 100644
index 000000000000..6795fe59cc9e
--- /dev/null
+++ b/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.styles.ts
@@ -0,0 +1,48 @@
+import { StyleSheet } from 'react-native';
+import type { Theme } from '../../../../../../util/theme/models';
+
+const styleSheet = (_params: { theme: Theme }) =>
+ StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ scrollContent: {
+ flexGrow: 1,
+ paddingHorizontal: 16,
+ paddingTop: 8,
+ paddingBottom: 16,
+ },
+ scrollView: {
+ flex: 1,
+ },
+ grid: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: 12,
+ },
+ gridItem: {
+ width: '48%',
+ },
+ skeletonGrid: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: 12,
+ },
+ skeletonCard: {
+ width: '48%',
+ height: 140,
+ borderRadius: 12,
+ backgroundColor: _params.theme.colors.background.section,
+ },
+ footer: {
+ flexDirection: 'column',
+ alignItems: 'flex-start',
+ paddingVertical: 4,
+ },
+ button: {
+ alignSelf: 'stretch',
+ width: '100%',
+ },
+ });
+
+export default styleSheet;
diff --git a/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.test.tsx b/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.test.tsx
new file mode 100644
index 000000000000..1da461be2494
--- /dev/null
+++ b/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.test.tsx
@@ -0,0 +1,277 @@
+import React from 'react';
+import { fireEvent, render } from '@testing-library/react-native';
+import WatchlistEmptyCTA from './WatchlistEmptyCTA';
+import { WatchlistEmptyCTATestIds } from './WatchlistEmptyCTA.testIds';
+import type { WatchlistTokenWithBalance } from '../../utils/addBalanceToTokens';
+
+const mockMutate = jest.fn();
+const mockTrackEvent = jest.fn();
+const mockCreateEventBuilder = jest.fn();
+const mockUseSuggestedWatchlistItemsQuery = jest.fn();
+const mockUseTokenWatchlistQuery = jest.fn();
+let mockAddMutationIsSuccess = false;
+
+jest.mock('../../hooks/useSuggestedWatchlistItemsQuery', () => ({
+ useSuggestedWatchlistItemsQuery: () => mockUseSuggestedWatchlistItemsQuery(),
+}));
+
+jest.mock('../../hooks/useTokenWatchlistQuery', () => ({
+ useTokenWatchlistQuery: () => mockUseTokenWatchlistQuery(),
+}));
+
+jest.mock('../../hooks/useTokenWatchlistMutations', () => ({
+ useTokenWatchlistAddItemMutation: () => ({
+ mutate: mockMutate,
+ isPending: false,
+ get isSuccess() {
+ return mockAddMutationIsSuccess;
+ },
+ }),
+}));
+
+jest.mock('../../../../../hooks/useAnalytics/useAnalytics', () => ({
+ useAnalytics: () => ({
+ trackEvent: mockTrackEvent,
+ createEventBuilder: mockCreateEventBuilder,
+ }),
+}));
+
+jest.mock('react-native-safe-area-context', () => ({
+ useSafeAreaInsets: () => ({ bottom: 34, top: 0, left: 0, right: 0 }),
+}));
+
+jest.mock('../WatchlistDefaultTokenCard', () => {
+ const { Text, TouchableOpacity, View } = jest.requireActual('react-native');
+ const ReactActual = jest.requireActual('react');
+ const Mock = ({
+ token,
+ isSelected,
+ onToggle,
+ }: {
+ token: { assetId: string; symbol: string };
+ isSelected: boolean;
+ onToggle: (assetId: string) => void;
+ }) =>
+ ReactActual.createElement(
+ TouchableOpacity,
+ {
+ testID: `mock-card-${token.assetId}`,
+ onPress: () => onToggle(String(token.assetId)),
+ },
+ ReactActual.createElement(Text, null, token.symbol),
+ ReactActual.createElement(
+ Text,
+ { testID: `mock-card-selected-${token.assetId}` },
+ isSelected ? 'selected' : 'unselected',
+ ),
+ );
+ Mock.displayName = 'WatchlistDefaultTokenCard';
+ return Mock;
+});
+
+const makeToken = (
+ assetId: string,
+ symbol: string,
+): WatchlistTokenWithBalance => ({
+ assetId,
+ symbol,
+ name: symbol,
+ decimals: 18,
+ balance: '0',
+ isInWallet: false,
+ marketData: { pricePercentChange24h: 1.2 },
+});
+
+describe('WatchlistEmptyCTA', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockAddMutationIsSuccess = false;
+ mockMutate.mockImplementation(() => undefined);
+ mockCreateEventBuilder.mockReturnValue({
+ addProperties: jest.fn().mockReturnThis(),
+ build: jest.fn().mockReturnValue({ name: 'Watchlist Token Added' }),
+ });
+ mockUseSuggestedWatchlistItemsQuery.mockReturnValue({
+ data: [
+ makeToken('eip155:1/slip44:60', 'ETH'),
+ makeToken('bip122:000000000019d6689c085ae165831e93/slip44:0', 'BTC'),
+ ],
+ isLoading: false,
+ });
+ mockUseTokenWatchlistQuery.mockReturnValue({
+ data: [],
+ isFetching: false,
+ });
+ });
+
+ it('renders all suggested tokens selected by default', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(
+ getByTestId('mock-card-selected-eip155:1/slip44:60').props.children,
+ ).toBe('selected');
+ expect(
+ getByTestId(
+ 'mock-card-selected-bip122:000000000019d6689c085ae165831e93/slip44:0',
+ ).props.children,
+ ).toBe('selected');
+ });
+
+ it('renders sticky footer matching TDP layout', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ const footer = getByTestId('bottomsheetfooter');
+ expect(footer).toBeDefined();
+ expect(footer.props.style).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ flexDirection: 'column',
+ alignItems: 'flex-start',
+ }),
+ expect.objectContaining({
+ paddingHorizontal: 16,
+ paddingTop: 16,
+ paddingBottom: 40,
+ }),
+ expect.not.objectContaining({ flex: 1 }),
+ ]),
+ );
+ });
+
+ it('shows add button with selected count', () => {
+ const { getByTestId, getByText } = render(
+ ,
+ );
+
+ expect(getByTestId(WatchlistEmptyCTATestIds.ADD_BUTTON)).toBeDefined();
+ expect(getByText('Add 2 tokens')).toBeDefined();
+ });
+
+ it('uses singular copy when one token is selected', () => {
+ const { getByTestId, getByText } = render(
+ ,
+ );
+
+ fireEvent.press(
+ getByTestId('mock-card-bip122:000000000019d6689c085ae165831e93/slip44:0'),
+ );
+
+ expect(getByText('Add 1 token')).toBeDefined();
+ });
+
+ it('disables add button when no tokens are selected', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ fireEvent.press(getByTestId('mock-card-eip155:1/slip44:60'));
+ fireEvent.press(
+ getByTestId('mock-card-bip122:000000000019d6689c085ae165831e93/slip44:0'),
+ );
+
+ expect(
+ getByTestId(WatchlistEmptyCTATestIds.ADD_BUTTON).props.accessibilityState
+ ?.disabled,
+ ).toBe(true);
+ });
+
+ it('calls add mutation with selected asset IDs', () => {
+ mockMutate.mockImplementation((_assetIds, options) => {
+ mockAddMutationIsSuccess = true;
+ options?.onSuccess?.();
+ });
+
+ const { getByTestId } = render(
+ ,
+ );
+
+ fireEvent.press(getByTestId('mock-card-eip155:1/slip44:60'));
+ fireEvent.press(getByTestId(WatchlistEmptyCTATestIds.ADD_BUTTON));
+
+ expect(mockMutate).toHaveBeenCalledWith(
+ ['bip122:000000000019d6689c085ae165831e93/slip44:0'],
+ expect.objectContaining({
+ onSuccess: expect.any(Function),
+ onError: expect.any(Function),
+ }),
+ );
+ expect(mockTrackEvent).toHaveBeenCalledTimes(1);
+ });
+
+ it('keeps add button disabled after submit while hydrated list refetches', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ const addButton = getByTestId(WatchlistEmptyCTATestIds.ADD_BUTTON);
+ fireEvent.press(addButton);
+
+ expect(addButton.props.accessibilityState?.disabled).toBe(true);
+ fireEvent.press(addButton);
+ expect(mockMutate).toHaveBeenCalledTimes(1);
+ });
+
+ it('re-enables add button when mutation fails', () => {
+ mockMutate.mockImplementation((_assetIds, options) => {
+ options?.onError?.();
+ });
+
+ const { getByTestId } = render(
+ ,
+ );
+
+ const addButton = getByTestId(WatchlistEmptyCTATestIds.ADD_BUTTON);
+ fireEvent.press(addButton);
+
+ expect(addButton.props.accessibilityState?.disabled).toBe(false);
+ });
+
+ it('re-enables add button when hydrated refetch settles without items', () => {
+ mockMutate.mockImplementation((_assetIds, options) => {
+ mockAddMutationIsSuccess = true;
+ options?.onSuccess?.();
+ });
+ mockUseTokenWatchlistQuery.mockReturnValue({
+ data: [],
+ isFetching: true,
+ });
+
+ const { getByTestId, rerender } = render(
+ ,
+ );
+
+ const addButton = getByTestId(WatchlistEmptyCTATestIds.ADD_BUTTON);
+ fireEvent.press(addButton);
+ expect(addButton.props.accessibilityState?.disabled).toBe(true);
+
+ mockUseTokenWatchlistQuery.mockReturnValue({
+ data: [],
+ isFetching: false,
+ });
+ rerender();
+
+ expect(
+ getByTestId(WatchlistEmptyCTATestIds.ADD_BUTTON).props.accessibilityState
+ ?.disabled,
+ ).toBe(false);
+ });
+
+ it('renders skeleton cards while loading', () => {
+ mockUseSuggestedWatchlistItemsQuery.mockReturnValue({
+ data: undefined,
+ isLoading: true,
+ });
+
+ const { getAllByTestId } = render(
+ ,
+ );
+
+ expect(
+ getAllByTestId(WatchlistEmptyCTATestIds.SKELETON).length,
+ ).toBeGreaterThan(0);
+ });
+});
diff --git a/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.testIds.ts b/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.testIds.ts
new file mode 100644
index 000000000000..34b260cea528
--- /dev/null
+++ b/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.testIds.ts
@@ -0,0 +1,6 @@
+export enum WatchlistEmptyCTATestIds {
+ CONTAINER = 'watchlist-empty-cta-container',
+ GRID = 'watchlist-empty-cta-grid',
+ SKELETON = 'watchlist-empty-cta-skeleton',
+ ADD_BUTTON = 'watchlist-empty-cta-add-button',
+}
diff --git a/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.tsx b/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.tsx
new file mode 100644
index 000000000000..d7462d9874bb
--- /dev/null
+++ b/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.tsx
@@ -0,0 +1,254 @@
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import { ScrollView, View } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import {
+ Button,
+ ButtonSize,
+ ButtonVariant,
+} from '@metamask/design-system-react-native';
+import type { CaipAssetType } from '@metamask/utils';
+import { useStyles } from '../../../../../../component-library/hooks';
+import { strings } from '../../../../../../../locales/i18n';
+import { MetaMetricsEvents } from '../../../../../../core/Analytics';
+import { useTheme } from '../../../../../../util/theme';
+import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics';
+import { useSuggestedWatchlistItemsQuery } from '../../hooks/useSuggestedWatchlistItemsQuery';
+import { useTokenWatchlistAddItemMutation } from '../../hooks/useTokenWatchlistMutations';
+import { useTokenWatchlistQuery } from '../../hooks/useTokenWatchlistQuery';
+import type { WatchlistTokenWithBalance } from '../../utils/addBalanceToTokens';
+import WatchlistDefaultTokenCard from '../WatchlistDefaultTokenCard';
+import styleSheet from './WatchlistEmptyCTA.styles';
+import { WatchlistEmptyCTATestIds } from './WatchlistEmptyCTA.testIds';
+
+const SKELETON_COUNT = 6;
+
+const getWatchlistAssetType = (assetId: string): 'native' | 'erc20' =>
+ assetId.includes('/erc20:') ? 'erc20' : 'native';
+
+interface WatchlistEmptyCTAProps {
+ /** Analytics source for watchlist add events. */
+ source: string;
+}
+
+const WatchlistEmptyCTA: React.FC = ({ source }) => {
+ const { styles } = useStyles(styleSheet, {});
+ const { colors } = useTheme();
+ const insets = useSafeAreaInsets();
+ const { data: suggestedTokens, isLoading } =
+ useSuggestedWatchlistItemsQuery();
+ const { data: hydratedTokens, isFetching: isHydratedFetching } =
+ useTokenWatchlistQuery();
+ const addMutation = useTokenWatchlistAddItemMutation();
+ const { trackEvent, createEventBuilder } = useAnalytics();
+
+ const [selectedAssetIds, setSelectedAssetIds] = useState>(
+ () => new Set(),
+ );
+ const [hasSubmitted, setHasSubmitted] = useState(false);
+ const initializedForTokensRef = useRef('');
+
+ useEffect(() => {
+ if (!suggestedTokens?.length) {
+ return;
+ }
+
+ const tokenKey = suggestedTokens.map((t) => String(t.assetId)).join(',');
+ if (initializedForTokensRef.current === tokenKey) {
+ return;
+ }
+
+ initializedForTokensRef.current = tokenKey;
+ setSelectedAssetIds(
+ new Set(suggestedTokens.map((token) => String(token.assetId))),
+ );
+ }, [suggestedTokens]);
+
+ useEffect(() => {
+ if (
+ !hasSubmitted ||
+ !addMutation.isSuccess ||
+ addMutation.isPending ||
+ isHydratedFetching ||
+ (hydratedTokens?.length ?? 0) > 0
+ ) {
+ return;
+ }
+
+ // Mutation succeeded but the empty CTA is still visible — allow retry when
+ // the hydrated refetch failed or did not populate the list.
+ setHasSubmitted(false);
+ }, [
+ addMutation.isPending,
+ addMutation.isSuccess,
+ hasSubmitted,
+ hydratedTokens,
+ isHydratedFetching,
+ ]);
+
+ const handleToggle = useCallback((assetId: string) => {
+ setSelectedAssetIds((prev) => {
+ const next = new Set(prev);
+ if (next.has(assetId)) {
+ next.delete(assetId);
+ } else {
+ next.add(assetId);
+ }
+ return next;
+ });
+ }, []);
+
+ const selectedCount = selectedAssetIds.size;
+
+ const addButtonLabel = useMemo(
+ () =>
+ selectedCount === 1
+ ? strings('token_watchlist.empty_add_tokens_cta', {
+ count: selectedCount,
+ })
+ : strings('token_watchlist.empty_add_tokens_cta_plural', {
+ count: selectedCount,
+ }),
+ [selectedCount],
+ );
+
+ const trackAdds = useCallback(
+ (tokens: WatchlistTokenWithBalance[]) => {
+ for (const token of tokens) {
+ trackEvent(
+ createEventBuilder(MetaMetricsEvents.WATCHLIST_TOKEN_ADDED)
+ .addProperties({
+ source,
+ asset_type: getWatchlistAssetType(String(token.assetId)),
+ has_balance: token.isInWallet,
+ })
+ .build(),
+ );
+ }
+ },
+ [createEventBuilder, source, trackEvent],
+ );
+
+ const handleAddPress = useCallback(() => {
+ if (!suggestedTokens?.length || selectedCount === 0 || hasSubmitted) {
+ return;
+ }
+
+ const toAdd = suggestedTokens.filter((token) =>
+ selectedAssetIds.has(String(token.assetId)),
+ );
+ const assetIds = toAdd.map((token) => token.assetId as CaipAssetType);
+
+ setHasSubmitted(true);
+ addMutation.mutate(assetIds, {
+ onSuccess: () => {
+ trackAdds(toAdd);
+ },
+ onError: () => {
+ setHasSubmitted(false);
+ },
+ });
+ }, [
+ addMutation,
+ hasSubmitted,
+ selectedAssetIds,
+ selectedCount,
+ suggestedTokens,
+ trackAdds,
+ ]);
+
+ const gridContent = useMemo(() => {
+ if (isLoading) {
+ return (
+
+ {Array.from({ length: SKELETON_COUNT }, (_, index) => (
+
+ ))}
+
+ );
+ }
+
+ if (!suggestedTokens?.length) {
+ return null;
+ }
+
+ return (
+
+ {suggestedTokens.map((token) => {
+ const assetId = String(token.assetId);
+ return (
+
+
+
+ );
+ })}
+
+ );
+ }, [
+ handleToggle,
+ isLoading,
+ selectedAssetIds,
+ styles.grid,
+ styles.gridItem,
+ styles.skeletonCard,
+ styles.skeletonGrid,
+ suggestedTokens,
+ ]);
+
+ const footerStyle = useMemo(
+ () => ({
+ backgroundColor: colors.background.default,
+ paddingHorizontal: 16,
+ paddingTop: 16,
+ paddingBottom: insets.bottom + 6,
+ }),
+ [colors.background.default, insets.bottom],
+ );
+
+ return (
+
+
+ {gridContent}
+
+
+
+
+
+ );
+};
+
+export default WatchlistEmptyCTA;
diff --git a/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/index.ts b/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/index.ts
new file mode 100644
index 000000000000..f728fede8102
--- /dev/null
+++ b/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/index.ts
@@ -0,0 +1 @@
+export { default } from './WatchlistEmptyCTA';
diff --git a/app/components/UI/Assets/watchlist/constants/defaultWatchlistTokens.ts b/app/components/UI/Assets/watchlist/constants/defaultWatchlistTokens.ts
new file mode 100644
index 000000000000..56fc9e5aa6fd
--- /dev/null
+++ b/app/components/UI/Assets/watchlist/constants/defaultWatchlistTokens.ts
@@ -0,0 +1,12 @@
+/** Curated default watchlist asset IDs for the empty-state CTA (mainnet). */
+export const DEFAULT_WATCHLIST_BASE_ASSET_IDS: readonly string[] = [
+ 'bip122:000000000019d6689c085ae165831e93/slip44:0',
+ 'eip155:1/slip44:60',
+ 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501',
+ 'eip155:56/slip44:714',
+ 'eip155:1/erc20:0x6982508145454Ce325dDbE47a25d4ec3d2311933',
+] as const;
+
+/** SpaceX (Ondo tokenized) on Ethereum mainnet — 6th default when geo-eligible. */
+export const SPACEX_DEFAULT_ASSET_ID =
+ 'eip155:1/erc20:0xc9eef266834730340a55b6cc24621b31baf55581' as const;
diff --git a/app/components/UI/Assets/watchlist/hooks/index.ts b/app/components/UI/Assets/watchlist/hooks/index.ts
index e29339c9a638..4c495efcaf8e 100644
--- a/app/components/UI/Assets/watchlist/hooks/index.ts
+++ b/app/components/UI/Assets/watchlist/hooks/index.ts
@@ -13,3 +13,4 @@ export {
type WatchlistRemoveInput,
type WatchlistUpdateListInput,
} from './useTokenWatchlistMutations';
+export { useSuggestedWatchlistItemsQuery } from './useSuggestedWatchlistItemsQuery';
diff --git a/app/components/UI/Assets/watchlist/hooks/useSuggestedWatchlistItemsQuery.test.ts b/app/components/UI/Assets/watchlist/hooks/useSuggestedWatchlistItemsQuery.test.ts
index 81b2b0ffbcd5..735ffba3823f 100644
--- a/app/components/UI/Assets/watchlist/hooks/useSuggestedWatchlistItemsQuery.test.ts
+++ b/app/components/UI/Assets/watchlist/hooks/useSuggestedWatchlistItemsQuery.test.ts
@@ -1,41 +1,87 @@
+import { renderHook } from '@testing-library/react-native';
+import { useSelector } from 'react-redux';
import { useTokenWatchlistQuery } from './useTokenWatchlistQuery';
+import { useSuggestedWatchlistItemsQuery } from './useSuggestedWatchlistItemsQuery';
import {
- SUGGESTED_WATCHLIST_ASSET_IDS,
- useSuggestedWatchlistItemsQuery,
-} from './useSuggestedWatchlistItemsQuery';
+ DEFAULT_WATCHLIST_BASE_ASSET_IDS,
+ SPACEX_DEFAULT_ASSET_ID,
+} from '../constants/defaultWatchlistTokens';
+
+jest.mock('react-redux', () => ({
+ useSelector: jest.fn(),
+}));
jest.mock('./useTokenWatchlistQuery', () => ({
useTokenWatchlistQuery: jest.fn(),
}));
+jest.mock('../utils/defaultWatchlistGeo', () => ({
+ isSpaceXDefaultEligible: jest.fn(),
+ getDefaultWatchlistAssetIds: jest.fn(),
+}));
+
+import {
+ getDefaultWatchlistAssetIds,
+ isSpaceXDefaultEligible,
+} from '../utils/defaultWatchlistGeo';
+
+const mockedUseSelector = useSelector as unknown as jest.Mock;
const mockedUseTokenWatchlistQuery =
useTokenWatchlistQuery as jest.MockedFunction;
+const mockedIsSpaceXDefaultEligible =
+ isSpaceXDefaultEligible as jest.MockedFunction<
+ typeof isSpaceXDefaultEligible
+ >;
+const mockedGetDefaultWatchlistAssetIds =
+ getDefaultWatchlistAssetIds as jest.MockedFunction<
+ typeof getDefaultWatchlistAssetIds
+ >;
describe('useSuggestedWatchlistItemsQuery', () => {
beforeEach(() => {
jest.clearAllMocks();
- });
-
- it('exposes the three curated mainnet native asset IDs (ETH/BTC/SOL)', () => {
- expect(SUGGESTED_WATCHLIST_ASSET_IDS).toStrictEqual([
- 'eip155:1/slip44:60',
- 'bip122:000000000019d6689c085ae165831e93/slip44:0',
- 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501',
+ mockedUseSelector.mockReturnValue('DE');
+ mockedIsSpaceXDefaultEligible.mockReturnValue(true);
+ mockedGetDefaultWatchlistAssetIds.mockReturnValue([
+ ...DEFAULT_WATCHLIST_BASE_ASSET_IDS,
+ SPACEX_DEFAULT_ASSET_ID,
]);
});
- it('delegates to useTokenWatchlistQuery with the curated suggested IDs', () => {
+ it('delegates to useTokenWatchlistQuery with geo-aware suggested IDs', () => {
const stubResult = { data: undefined, isSuccess: false };
mockedUseTokenWatchlistQuery.mockReturnValue(
stubResult as unknown as ReturnType,
);
- const returned = useSuggestedWatchlistItemsQuery();
+ const { result } = renderHook(() => useSuggestedWatchlistItemsQuery());
+
+ expect(mockedGetDefaultWatchlistAssetIds).toHaveBeenCalledWith('DE');
+ expect(mockedUseTokenWatchlistQuery).toHaveBeenCalledWith({
+ suggestedTokens: [
+ ...DEFAULT_WATCHLIST_BASE_ASSET_IDS,
+ SPACEX_DEFAULT_ASSET_ID,
+ ],
+ suggestedIncludeSpaceX: true,
+ });
+ expect(result.current).toBe(stubResult);
+ });
+
+ it('passes suggestedIncludeSpaceX false when SpaceX is not eligible', () => {
+ mockedUseSelector.mockReturnValue('US');
+ mockedIsSpaceXDefaultEligible.mockReturnValue(false);
+ mockedGetDefaultWatchlistAssetIds.mockReturnValue(
+ DEFAULT_WATCHLIST_BASE_ASSET_IDS,
+ );
+ mockedUseTokenWatchlistQuery.mockReturnValue(
+ {} as ReturnType,
+ );
+
+ renderHook(() => useSuggestedWatchlistItemsQuery());
- expect(mockedUseTokenWatchlistQuery).toHaveBeenCalledTimes(1);
expect(mockedUseTokenWatchlistQuery).toHaveBeenCalledWith({
- suggestedTokens: SUGGESTED_WATCHLIST_ASSET_IDS,
+ suggestedTokens: DEFAULT_WATCHLIST_BASE_ASSET_IDS,
+ suggestedIncludeSpaceX: false,
});
- expect(returned).toBe(stubResult);
});
});
diff --git a/app/components/UI/Assets/watchlist/hooks/useSuggestedWatchlistItemsQuery.ts b/app/components/UI/Assets/watchlist/hooks/useSuggestedWatchlistItemsQuery.ts
index 36edea992e0b..57f4f8d790f9 100644
--- a/app/components/UI/Assets/watchlist/hooks/useSuggestedWatchlistItemsQuery.ts
+++ b/app/components/UI/Assets/watchlist/hooks/useSuggestedWatchlistItemsQuery.ts
@@ -1,16 +1,35 @@
+import { useMemo } from 'react';
+import { useSelector } from 'react-redux';
import { type UseQueryResult } from '@tanstack/react-query';
+import { getDetectedGeolocation } from '../../../../../reducers/fiatOrders';
import { type WatchlistTokenWithBalance } from '../utils/addBalanceToTokens';
+import {
+ getDefaultWatchlistAssetIds,
+ isSpaceXDefaultEligible,
+} from '../utils/defaultWatchlistGeo';
import { useTokenWatchlistQuery } from './useTokenWatchlistQuery';
-/** Curated ETH / BTC / SOL native asset IDs surfaced in the empty-state CTA. */
-export const SUGGESTED_WATCHLIST_ASSET_IDS: readonly string[] = [
- 'eip155:1/slip44:60',
- 'bip122:000000000019d6689c085ae165831e93/slip44:0',
- 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501',
-] as const;
+export { DEFAULT_WATCHLIST_BASE_ASSET_IDS } from '../constants/defaultWatchlistTokens';
+/**
+ * Hydrates curated default watchlist tokens for the empty-state CTA.
+ * Geo-aware: eligible users receive SpaceX as a 6th default.
+ */
export const useSuggestedWatchlistItemsQuery = (): UseQueryResult<
WatchlistTokenWithBalance[],
Error
-> => useTokenWatchlistQuery({ suggestedTokens: SUGGESTED_WATCHLIST_ASSET_IDS });
+> => {
+ const geolocation = useSelector(getDetectedGeolocation);
+ const includeSpaceX = isSpaceXDefaultEligible(geolocation);
+
+ const suggestedTokens = useMemo(
+ () => getDefaultWatchlistAssetIds(geolocation),
+ [geolocation],
+ );
+
+ return useTokenWatchlistQuery({
+ suggestedTokens,
+ suggestedIncludeSpaceX: includeSpaceX,
+ });
+};
diff --git a/app/components/UI/Assets/watchlist/hooks/useTokenWatchlistQuery.test.ts b/app/components/UI/Assets/watchlist/hooks/useTokenWatchlistQuery.test.ts
index 3f1504f12f75..8a5d02a08639 100644
--- a/app/components/UI/Assets/watchlist/hooks/useTokenWatchlistQuery.test.ts
+++ b/app/components/UI/Assets/watchlist/hooks/useTokenWatchlistQuery.test.ts
@@ -305,7 +305,7 @@ describe('useTokenWatchlistQuery', () => {
});
expect(
- queryClient.getQueryData(tokenWatchlistQueryKeys.suggested),
+ queryClient.getQueryData(tokenWatchlistQueryKeys.suggested(false)),
).toStrictEqual([]);
expect(
queryClient.getQueryData(tokenWatchlistQueryKeys.hydrated),
diff --git a/app/components/UI/Assets/watchlist/hooks/useTokenWatchlistQuery.ts b/app/components/UI/Assets/watchlist/hooks/useTokenWatchlistQuery.ts
index 711622ae22d6..754eb0644426 100644
--- a/app/components/UI/Assets/watchlist/hooks/useTokenWatchlistQuery.ts
+++ b/app/components/UI/Assets/watchlist/hooks/useTokenWatchlistQuery.ts
@@ -19,6 +19,8 @@ export const WATCHLIST_QUERY_STALE_TIME_MS = 60_000;
export interface UseTokenWatchlistQueryOptions {
/** When provided, bypass the stored watchlist and hydrate these IDs instead. */
suggestedTokens?: readonly string[];
+ /** When hydrating suggested tokens, vary the cache key if SpaceX is included. */
+ suggestedIncludeSpaceX?: boolean;
}
/**
@@ -29,7 +31,7 @@ export interface UseTokenWatchlistQueryOptions {
export const useTokenWatchlistQuery = (
options: UseTokenWatchlistQueryOptions = {},
): UseQueryResult => {
- const { suggestedTokens } = options;
+ const { suggestedTokens, suggestedIncludeSpaceX = false } = options;
const isWatchlistEnabled = useSelector(selectTokenWatchlistEnabled);
@@ -44,7 +46,7 @@ export const useTokenWatchlistQuery = (
return useQuery({
queryKey: suggestedTokens
- ? tokenWatchlistQueryKeys.suggested
+ ? tokenWatchlistQueryKeys.suggested(suggestedIncludeSpaceX)
: tokenWatchlistQueryKeys.hydrated,
staleTime: WATCHLIST_QUERY_STALE_TIME_MS,
enabled: isWatchlistEnabled,
diff --git a/app/components/UI/Assets/watchlist/hooks/watchlist-query-keys.ts b/app/components/UI/Assets/watchlist/hooks/watchlist-query-keys.ts
index e6f3e717fc10..4f8daa6a0bd6 100644
--- a/app/components/UI/Assets/watchlist/hooks/watchlist-query-keys.ts
+++ b/app/components/UI/Assets/watchlist/hooks/watchlist-query-keys.ts
@@ -2,5 +2,10 @@ export const tokenWatchlistQueryKeys = {
all: ['tokenWatchlist'] as const,
blob: ['tokenWatchlist', 'blob'] as const,
hydrated: ['tokenWatchlist', 'hydrated'] as const,
- suggested: ['tokenWatchlist', 'suggested'] as const,
+ suggested: (includeSpaceX: boolean) =>
+ [
+ 'tokenWatchlist',
+ 'suggested',
+ includeSpaceX ? 'with-spacex' : 'base',
+ ] as const,
};
diff --git a/app/components/UI/Assets/watchlist/utils/defaultWatchlistGeo.test.ts b/app/components/UI/Assets/watchlist/utils/defaultWatchlistGeo.test.ts
new file mode 100644
index 000000000000..c18aef2fec91
--- /dev/null
+++ b/app/components/UI/Assets/watchlist/utils/defaultWatchlistGeo.test.ts
@@ -0,0 +1,57 @@
+import {
+ getDefaultWatchlistAssetIds,
+ isSpaceXDefaultEligible,
+ SPACEX_DEFAULT_GEO_BLOCKED_COUNTRIES,
+ SPACEX_DEFAULT_GEO_BLOCKED_REGIONS,
+} from './defaultWatchlistGeo';
+import {
+ DEFAULT_WATCHLIST_BASE_ASSET_IDS,
+ SPACEX_DEFAULT_ASSET_ID,
+} from '../constants/defaultWatchlistTokens';
+
+describe('defaultWatchlistGeo', () => {
+ describe('isSpaceXDefaultEligible', () => {
+ it('returns false when geolocation is missing or unknown', () => {
+ expect(isSpaceXDefaultEligible(undefined)).toBe(false);
+ expect(isSpaceXDefaultEligible('UNKNOWN')).toBe(false);
+ });
+
+ it('returns false for blocked countries', () => {
+ for (const country of SPACEX_DEFAULT_GEO_BLOCKED_COUNTRIES) {
+ expect(isSpaceXDefaultEligible(country)).toBe(false);
+ expect(isSpaceXDefaultEligible(`${country}-XX`)).toBe(false);
+ }
+ });
+
+ it('returns false for blocked sub-regions', () => {
+ for (const region of SPACEX_DEFAULT_GEO_BLOCKED_REGIONS) {
+ expect(isSpaceXDefaultEligible(region)).toBe(false);
+ }
+ });
+
+ it('returns true for eligible countries', () => {
+ expect(isSpaceXDefaultEligible('DE')).toBe(true);
+ expect(isSpaceXDefaultEligible('GB')).toBe(true);
+ expect(isSpaceXDefaultEligible('AR')).toBe(true);
+ expect(isSpaceXDefaultEligible('de-be')).toBe(true);
+ });
+ });
+
+ describe('getDefaultWatchlistAssetIds', () => {
+ it('returns base 5 tokens when SpaceX is not eligible', () => {
+ expect(getDefaultWatchlistAssetIds('US')).toStrictEqual(
+ DEFAULT_WATCHLIST_BASE_ASSET_IDS,
+ );
+ expect(getDefaultWatchlistAssetIds(undefined)).toStrictEqual(
+ DEFAULT_WATCHLIST_BASE_ASSET_IDS,
+ );
+ });
+
+ it('returns base 5 plus SpaceX when eligible', () => {
+ expect(getDefaultWatchlistAssetIds('DE')).toStrictEqual([
+ ...DEFAULT_WATCHLIST_BASE_ASSET_IDS,
+ SPACEX_DEFAULT_ASSET_ID,
+ ]);
+ });
+ });
+});
diff --git a/app/components/UI/Assets/watchlist/utils/defaultWatchlistGeo.ts b/app/components/UI/Assets/watchlist/utils/defaultWatchlistGeo.ts
new file mode 100644
index 000000000000..16f2c360cd29
--- /dev/null
+++ b/app/components/UI/Assets/watchlist/utils/defaultWatchlistGeo.ts
@@ -0,0 +1,72 @@
+import {
+ DEFAULT_WATCHLIST_BASE_ASSET_IDS,
+ SPACEX_DEFAULT_ASSET_ID,
+} from '../constants/defaultWatchlistTokens';
+
+/** Countries where SpaceX must not appear in watchlist defaults (Slack / product). */
+export const SPACEX_DEFAULT_GEO_BLOCKED_COUNTRIES = new Set([
+ 'US',
+ 'CA',
+ 'AF',
+ 'BY',
+ 'CU',
+ 'KP',
+ 'IR',
+ 'LY',
+ 'MM',
+ 'RU',
+ 'SO',
+ 'SS',
+ 'SD',
+ 'SY',
+]);
+
+/**
+ * Sub-regions (Crimea, DNR, LNR, etc.) where SpaceX must not appear.
+ * ISO 3166-2 codes; matched against the full geolocation string.
+ */
+export const SPACEX_DEFAULT_GEO_BLOCKED_REGIONS = new Set([
+ 'UA-43',
+ 'UA-14',
+ 'UA-09',
+ 'UA-65',
+ 'RU-43',
+ 'RU-92',
+ 'RU-09',
+ 'RU-23',
+]);
+
+/**
+ * Returns whether SpaceX may be included in watchlist default suggestions.
+ * Fail-closed: unknown or missing geo → not eligible.
+ */
+export const isSpaceXDefaultEligible = (
+ location: string | undefined,
+): boolean => {
+ if (!location || location.toUpperCase() === 'UNKNOWN') {
+ return false;
+ }
+
+ const upper = location.toUpperCase();
+ const country = upper.split('-')[0];
+
+ if (SPACEX_DEFAULT_GEO_BLOCKED_COUNTRIES.has(country)) {
+ return false;
+ }
+
+ if (SPACEX_DEFAULT_GEO_BLOCKED_REGIONS.has(upper)) {
+ return false;
+ }
+
+ return true;
+};
+
+/** Resolves the ordered default asset IDs for the empty-state CTA. */
+export const getDefaultWatchlistAssetIds = (
+ location: string | undefined,
+): readonly string[] => {
+ if (isSpaceXDefaultEligible(location)) {
+ return [...DEFAULT_WATCHLIST_BASE_ASSET_IDS, SPACEX_DEFAULT_ASSET_ID];
+ }
+ return DEFAULT_WATCHLIST_BASE_ASSET_IDS;
+};
diff --git a/locales/languages/en.json b/locales/languages/en.json
index 21a0a4af61ab..6d573701b97b 100644
--- a/locales/languages/en.json
+++ b/locales/languages/en.json
@@ -10433,6 +10433,8 @@
"fullscreen_title": "Watchlist",
"tokens_tab": "Tokens",
"edit": "Edit",
- "done": "Done"
+ "done": "Done",
+ "empty_add_tokens_cta": "Add {{count}} token",
+ "empty_add_tokens_cta_plural": "Add {{count}} tokens"
}
}