diff --git a/.eslintrc.js b/.eslintrc.js index 01caff35758..147bbca7708 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -385,6 +385,33 @@ module.exports = { ], }, }, + { + files: ['**/*.test.{js,ts,tsx,jsx}', '**/*.spec.{js,ts,tsx,jsx}'], + plugins: ['jest'], + rules: { + // Prevent new file-based snapshots. Inline snapshots (toMatchInlineSnapshot) + // are still allowed as they keep assertions co-located with the test. + 'jest/no-restricted-matchers': [ + 'error', + { + toMatchSnapshot: + 'Use toMatchInlineSnapshot() or an explicit assertion instead. File-based snapshots are being phased out.', + }, + ], + }, + }, + { + // Matches CODEOWNERS `**/snaps/**` and `**/Snaps/**` (@MetaMask/core-platform). + // ESLint cannot read CODEOWNERS. + files: [ + '**/snaps/**/*.{test,spec}.{js,ts,tsx,jsx}', + '**/Snaps/**/*.{test,spec}.{js,ts,tsx,jsx}', + ], + plugins: ['jest'], + rules: { + 'jest/no-restricted-matchers': 'off', + }, + }, // ── Perps controller Core-alignment override ── // Enforces the same ESLint rules that Core's @metamask/eslint-config // applies to packages/perps-controller so that code written in mobile diff --git a/app/component-library/components/Badges/BadgeWrapper/BadgeWrapper.test.tsx b/app/component-library/components/Badges/BadgeWrapper/BadgeWrapper.test.tsx index 2477b22bc19..a75ce002b06 100644 --- a/app/component-library/components/Badges/BadgeWrapper/BadgeWrapper.test.tsx +++ b/app/component-library/components/Badges/BadgeWrapper/BadgeWrapper.test.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { render, screen } from '@testing-library/react-native'; // Internal dependencies. +import { BADGENETWORK_TEST_ID } from '../Badge/variants/BadgeNetwork/BadgeNetwork.constants'; import BadgeWrapper from './BadgeWrapper'; import { SAMPLE_BADGEWRAPPER_PROPS, @@ -10,9 +11,12 @@ import { } from './BadgeWrapper.constants'; describe('BadgeWrapper', () => { - it('should render BadgeWrapper correctly', () => { - const { toJSON } = render(); - expect(toJSON()).toMatchSnapshot(); - expect(screen.getByTestId(BADGE_WRAPPER_BADGE_TEST_ID)).toBeDefined(); + it('renders anchor content, network badge, and wrapper test id', () => { + render(); + + expect(screen.getByTestId(BADGE_WRAPPER_BADGE_TEST_ID)).toBeOnTheScreen(); + expect(screen.getByText('C')).toBeOnTheScreen(); + expect(screen.getByTestId(BADGENETWORK_TEST_ID)).toBeOnTheScreen(); + expect(screen.getByTestId('network-avatar-image')).toBeOnTheScreen(); }); }); diff --git a/app/component-library/components/Badges/BadgeWrapper/__snapshots__/BadgeWrapper.test.tsx.snap b/app/component-library/components/Badges/BadgeWrapper/__snapshots__/BadgeWrapper.test.tsx.snap deleted file mode 100644 index d17f51349a5..00000000000 --- a/app/component-library/components/Badges/BadgeWrapper/__snapshots__/BadgeWrapper.test.tsx.snap +++ /dev/null @@ -1,122 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`BadgeWrapper should render BadgeWrapper correctly 1`] = ` - - - - - C - - - - - - - - - - - -`; diff --git a/app/component-library/components/Toast/Toast.styles.ts b/app/component-library/components/Toast/Toast.styles.ts index ca5172d145e..0ad737cb36a 100644 --- a/app/component-library/components/Toast/Toast.styles.ts +++ b/app/component-library/components/Toast/Toast.styles.ts @@ -26,14 +26,15 @@ const styleSheet = (params: { theme: Theme }) => { borderRadius: 12, padding, flexDirection: 'row', - alignItems: 'center', + alignItems: 'flex-start', }, avatar: { + marginTop: -4, marginRight: 16, }, labelsContainer: { flex: 1, - justifyContent: 'center', + justifyContent: 'flex-start', }, label: { color: colors.text.default, diff --git a/app/component-library/components/Toast/Toast.test.tsx b/app/component-library/components/Toast/Toast.test.tsx index 40a45907683..dbbb64292d5 100644 --- a/app/component-library/components/Toast/Toast.test.tsx +++ b/app/component-library/components/Toast/Toast.test.tsx @@ -1,10 +1,12 @@ // Third party dependencies. import React, { createRef } from 'react'; +import { StyleSheet } from 'react-native'; import { render, screen, act } from '@testing-library/react-native'; // Internal dependencies. import Toast from './Toast'; import { ToastRef, ToastVariants, ToastOptions } from './Toast.types'; +import { ToastSelectorsIDs } from './ToastModal.testIds'; // react-native-reanimated is already mocked globally via setUpTests() in testSetup.js @@ -163,4 +165,24 @@ describe('Toast', () => { expect(screen.queryByText('In Progress')).toBeNull(); expect(screen.getByText('Success')).toBeOnTheScreen(); }); + + it('uses flex-start justifyContent on labels container by default', async () => { + const toastOptions: ToastOptions = { + variant: ToastVariants.Plain, + labelOptions: [{ label: 'Aligned label' }], + hasNoTimeout: true, + }; + + render(); + + await act(async () => { + toastRef.current?.showToast(toastOptions); + jest.runAllTimers(); + }); + + const labelsContainer = screen.getByTestId(ToastSelectorsIDs.CONTAINER); + const flat = StyleSheet.flatten(labelsContainer.props.style); + + expect(flat.justifyContent).toBe('flex-start'); + }); }); diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index 5d912ea4820..3533ad12698 100644 --- a/app/components/Nav/Main/MainNavigator.js +++ b/app/components/Nav/Main/MainNavigator.js @@ -51,6 +51,7 @@ import ContactForm from '../../Views/Settings/Contacts/ContactForm'; import ActivityView from '../../Views/ActivityView'; import RewardsNavigator from '../../UI/Rewards/RewardsNavigator'; import { ExploreFeed } from '../../Views/TrendingView/TrendingView'; +import WhatsHappeningDetailView from '../../Views/WhatsHappeningDetailView'; import ExploreSearchScreen from '../../Views/TrendingView/Views/ExploreSearchScreen/ExploreSearchScreen'; import ExploreSectionResultsFullView from '../../Views/TrendingView/Views/ExploreSectionResultsFullView/ExploreSectionResultsFullView'; import TrendingFeedSessionManager from '../../UI/Trending/services/TrendingFeedSessionManager'; @@ -1368,6 +1369,11 @@ const MainNavigator = () => { component={SitesFullView} options={{ headerShown: false, ...slideFromRightAnimation }} /> + { expect(screen).toBeDefined(); }); + it('includes WhatsHappeningDetailView screen', () => { + const container = renderWithProvider(, { + state: initialRootState, + }); + + const screenProps = getScreenProps(container); + const screen = screenProps?.find( + (s) => s?.name === Routes.WHATS_HAPPENING_DETAIL, + ); + + expect(screen).toBeDefined(); + }); + it('includes Browser home screen in main navigator', () => { const container = renderWithProvider(, { state: initialRootState, diff --git a/app/components/Nav/Main/__snapshots__/index.test.tsx.snap b/app/components/Nav/Main/__snapshots__/index.test.tsx.snap deleted file mode 100644 index c55aac0b41c..00000000000 --- a/app/components/Nav/Main/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,481 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Main should render correctly 1`] = ` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -`; - -exports[`Main should render correctly with isConnectionRemoved true 1`] = ` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -`; diff --git a/app/components/Nav/Main/index.test.tsx b/app/components/Nav/Main/index.test.tsx index dde617be43f..642344d59be 100644 --- a/app/components/Nav/Main/index.test.tsx +++ b/app/components/Nav/Main/index.test.tsx @@ -1,10 +1,12 @@ /* eslint-disable import-x/no-nodejs-modules */ import React from 'react'; +import { View } from 'react-native'; import Main from './'; import renderWithProvider from '../../../util/test/renderWithProvider'; import initialRootState from '../../../util/test/initial-root-state'; const mockReact = React; +const mockView = View; // Mock Ramp SDK dependencies to prevent SdkEnvironment.Production errors jest.mock('../../../components/UI/Ramp', () => ({ @@ -30,7 +32,8 @@ jest.mock('react-native-device-info', () => ({ // Mock heavy child components to avoid deep dependency issues jest.mock('./MainNavigator', () => { - const MockMainNavigator = () => mockReact.createElement('MainNavigatorMock'); + const MockMainNavigator = () => + mockReact.createElement(mockView, { testID: 'mocked-main-navigator' }); MockMainNavigator.router = {}; return { __esModule: true, @@ -113,8 +116,8 @@ describe('Main', () => { jest.clearAllMocks(); }); - it('should render correctly', () => { - const { toJSON } = renderWithProvider(
, { + it('mounts the main flow with mocked navigator and shell components', () => { + const { getByTestId } = renderWithProvider(
, { state: { ...initialRootState, user: { @@ -123,19 +126,7 @@ describe('Main', () => { }, }, }); - expect(toJSON()).toMatchSnapshot(); - }); - it('should render correctly with isConnectionRemoved true', () => { - const { toJSON } = renderWithProvider(
, { - state: { - ...initialRootState, - user: { - ...initialRootState.user, - isConnectionRemoved: true, - }, - }, - }); - expect(toJSON()).toMatchSnapshot(); + expect(getByTestId('mocked-main-navigator')).toBeOnTheScreen(); }); }); diff --git a/app/components/UI/AccountFromToInfoCard/AccountFromToInfoCard.test.tsx b/app/components/UI/AccountFromToInfoCard/AccountFromToInfoCard.test.tsx index c949d1f692e..bbd6049d191 100644 --- a/app/components/UI/AccountFromToInfoCard/AccountFromToInfoCard.test.tsx +++ b/app/components/UI/AccountFromToInfoCard/AccountFromToInfoCard.test.tsx @@ -1,7 +1,4 @@ import React from 'react'; -import { Provider } from 'react-redux'; -import { render } from '@testing-library/react-native'; -import configureMockStore from 'redux-mock-store'; import renderWithProvider, { DeepPartial, @@ -133,9 +130,6 @@ jest.mock('../../../util/address', () => ({ isQRHardwareAccount: jest.fn(), })); -const mockStore = configureMockStore(); -const store = mockStore(mockInitialState); - jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), useSelector: jest @@ -163,25 +157,6 @@ describe('AccountFromToInfoCard', () => { require('../../../store')._updateMockState(mockInitialState); }); - it('should render correctly', () => { - const { toJSON } = render( - - {/* @ts-expect-error: Rest props are ignored for testing purposes */} - - , - ); - expect(toJSON()).toMatchSnapshot(); - }); - - it('should match snapshot', () => { - const { toJSON } = renderWithProvider( - //@ts-expect-error - Rest props are ignored for testing purposes - , - { state: mockInitialState }, - ); - expect(toJSON()).not.toBeNull(); - }); - it('should render to account name', async () => { const { findByText } = renderWithProvider( //@ts-expect-error - Rest props are ignored for testing purposes diff --git a/app/components/UI/AccountFromToInfoCard/__snapshots__/AccountFromToInfoCard.test.tsx.snap b/app/components/UI/AccountFromToInfoCard/__snapshots__/AccountFromToInfoCard.test.tsx.snap deleted file mode 100644 index 688c7d24c30..00000000000 --- a/app/components/UI/AccountFromToInfoCard/__snapshots__/AccountFromToInfoCard.test.tsx.snap +++ /dev/null @@ -1,662 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`AccountFromToInfoCard should render correctly 1`] = ` - - - - - From: - - - - - - - - - - - - - - - - - - - - - - Ethereum Main Network - - - - Account 1 - - - - - - - Balance - - - < 0.00001 ETH - - - - - - - - - To: - - - - - - - - - - - - - - - - - Account 2 - - - - - 0x519d2...c9CC7 - - - -  - - - - - - - - - - - - - Check the recipient address - - - -  - - - - - - We have detected a confusable character in the ENS name. Check the ENS name to avoid a potential scam. - - - - - -`; diff --git a/app/components/UI/AssetElement/__snapshots__/index.test.tsx.snap b/app/components/UI/AssetElement/__snapshots__/index.test.tsx.snap deleted file mode 100644 index 1fea4f67819..00000000000 --- a/app/components/UI/AssetElement/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,51 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`AssetElement renders correctly 1`] = ` - - - -`; diff --git a/app/components/UI/AssetElement/index.test.tsx b/app/components/UI/AssetElement/index.test.tsx index 13554043ea6..5621cd0019f 100644 --- a/app/components/UI/AssetElement/index.test.tsx +++ b/app/components/UI/AssetElement/index.test.tsx @@ -34,9 +34,10 @@ describe('AssetElement', () => { jest.clearAllMocks(); }); - it('renders correctly', () => { - const { toJSON } = render(); - expect(toJSON()).toMatchSnapshot(); + it('renders the asset row with the expected test id when no balance is shown', () => { + const { getByTestId } = render(); + + expect(getByTestId(getAssetTestId(erc20Token.symbol))).toBeOnTheScreen(); }); it('renders the main balance if provided', () => { diff --git a/app/components/UI/Charts/AdvancedChart/webview/chartLogic.js b/app/components/UI/Charts/AdvancedChart/webview/chartLogic.js index ebaa8adf185..f509ddc036e 100644 --- a/app/components/UI/Charts/AdvancedChart/webview/chartLogic.js +++ b/app/components/UI/Charts/AdvancedChart/webview/chartLogic.js @@ -3579,6 +3579,125 @@ function initChart() { } : undefined; + // TradingView only supports a fixed set of IANA timezone IDs. + // If the device returns an unsupported ID we fall back to Etc/UTC. + // List of supported timezones: https://www.tradingview.com/charting-library-docs/latest/ui_elements/timezones#supported-time-zones + var TV_SUPPORTED_TIMEZONES = [ + 'Etc/UTC', + 'Africa/Cairo', + 'Africa/Casablanca', + 'Africa/Johannesburg', + 'Africa/Lagos', + 'Africa/Nairobi', + 'Africa/Tunis', + 'America/Anchorage', + 'America/Argentina/Buenos_Aires', + 'America/Bogota', + 'America/Caracas', + 'America/Chicago', + 'America/El_Salvador', + 'America/Halifax', + 'America/Juneau', + 'America/Lima', + 'America/Los_Angeles', + 'America/Mexico_City', + 'America/New_York', + 'America/Phoenix', + 'America/Santiago', + 'America/Sao_Paulo', + 'America/Toronto', + 'America/Vancouver', + 'Asia/Astana', + 'Asia/Ashkhabad', + 'Asia/Bahrain', + 'Asia/Bangkok', + 'Asia/Chongqing', + 'Asia/Colombo', + 'Asia/Dhaka', + 'Asia/Dubai', + 'Asia/Ho_Chi_Minh', + 'Asia/Hong_Kong', + 'Asia/Jakarta', + 'Asia/Jerusalem', + 'Asia/Karachi', + 'Asia/Kabul', + 'Asia/Kathmandu', + 'Asia/Kolkata', + 'Asia/Kuala_Lumpur', + 'Asia/Kuwait', + 'Asia/Manila', + 'Asia/Muscat', + 'Asia/Nicosia', + 'Asia/Qatar', + 'Asia/Riyadh', + 'Asia/Seoul', + 'Asia/Shanghai', + 'Asia/Singapore', + 'Asia/Taipei', + 'Asia/Tehran', + 'Asia/Tokyo', + 'Asia/Yangon', + 'Atlantic/Azores', + 'Atlantic/Reykjavik', + 'Australia/Adelaide', + 'Australia/Brisbane', + 'Australia/Perth', + 'Australia/Sydney', + 'Europe/Amsterdam', + 'Europe/Athens', + 'Europe/Belgrade', + 'Europe/Berlin', + 'Europe/Bratislava', + 'Europe/Brussels', + 'Europe/Bucharest', + 'Europe/Budapest', + 'Europe/Copenhagen', + 'Europe/Dublin', + 'Europe/Helsinki', + 'Europe/Istanbul', + 'Europe/Lisbon', + 'Europe/London', + 'Europe/Luxembourg', + 'Europe/Madrid', + 'Europe/Malta', + 'Europe/Moscow', + 'Europe/Oslo', + 'Europe/Paris', + 'Europe/Prague', + 'Europe/Riga', + 'Europe/Rome', + 'Europe/Stockholm', + 'Europe/Tallinn', + 'Europe/Vienna', + 'Europe/Vilnius', + 'Europe/Warsaw', + 'Europe/Zurich', + 'Pacific/Auckland', + 'Pacific/Chatham', + 'Pacific/Fakaofo', + 'Pacific/Honolulu', + 'Pacific/Norfolk', + 'US/Mountain', + ]; + + // Intl returns canonical IANA names, but TradingView uses some legacy aliases. + var CANONICAL_TO_TV = { + 'America/Denver': 'US/Mountain', + 'Asia/Ashgabat': 'Asia/Ashkhabad', + 'Asia/Almaty': 'Asia/Astana', + }; + + var userTimezone = (function () { + try { + var tz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'Etc/UTC'; + var mapped = CANONICAL_TO_TV[tz] || tz; + return TV_SUPPORTED_TIMEZONES.indexOf(mapped) !== -1 + ? mapped + : 'Etc/UTC'; + } catch (_e) { + return 'Etc/UTC'; + } + })(); window.chartWidget = new TradingView.widget({ symbol: window.currentSymbol, interval: window.currentResolution || '5', @@ -3587,6 +3706,7 @@ function initChart() { datafeed: customDatafeed, library_path: window.CONFIG.libraryUrl, locale: 'en', + timezone: userTimezone, fullscreen: false, autosize: true, theme: 'Dark', diff --git a/app/components/UI/Charts/AdvancedChart/webview/chartLogicString.ts b/app/components/UI/Charts/AdvancedChart/webview/chartLogicString.ts index 6404ea28b61..b73a59124d7 100644 --- a/app/components/UI/Charts/AdvancedChart/webview/chartLogicString.ts +++ b/app/components/UI/Charts/AdvancedChart/webview/chartLogicString.ts @@ -3588,6 +3588,125 @@ function initChart() { } : undefined; + // TradingView only supports a fixed set of IANA timezone IDs. + // If the device returns an unsupported ID we fall back to Etc/UTC. + // List of supported timezones: https://www.tradingview.com/charting-library-docs/latest/ui_elements/timezones#supported-time-zones + var TV_SUPPORTED_TIMEZONES = [ + 'Etc/UTC', + 'Africa/Cairo', + 'Africa/Casablanca', + 'Africa/Johannesburg', + 'Africa/Lagos', + 'Africa/Nairobi', + 'Africa/Tunis', + 'America/Anchorage', + 'America/Argentina/Buenos_Aires', + 'America/Bogota', + 'America/Caracas', + 'America/Chicago', + 'America/El_Salvador', + 'America/Halifax', + 'America/Juneau', + 'America/Lima', + 'America/Los_Angeles', + 'America/Mexico_City', + 'America/New_York', + 'America/Phoenix', + 'America/Santiago', + 'America/Sao_Paulo', + 'America/Toronto', + 'America/Vancouver', + 'Asia/Astana', + 'Asia/Ashkhabad', + 'Asia/Bahrain', + 'Asia/Bangkok', + 'Asia/Chongqing', + 'Asia/Colombo', + 'Asia/Dhaka', + 'Asia/Dubai', + 'Asia/Ho_Chi_Minh', + 'Asia/Hong_Kong', + 'Asia/Jakarta', + 'Asia/Jerusalem', + 'Asia/Karachi', + 'Asia/Kabul', + 'Asia/Kathmandu', + 'Asia/Kolkata', + 'Asia/Kuala_Lumpur', + 'Asia/Kuwait', + 'Asia/Manila', + 'Asia/Muscat', + 'Asia/Nicosia', + 'Asia/Qatar', + 'Asia/Riyadh', + 'Asia/Seoul', + 'Asia/Shanghai', + 'Asia/Singapore', + 'Asia/Taipei', + 'Asia/Tehran', + 'Asia/Tokyo', + 'Asia/Yangon', + 'Atlantic/Azores', + 'Atlantic/Reykjavik', + 'Australia/Adelaide', + 'Australia/Brisbane', + 'Australia/Perth', + 'Australia/Sydney', + 'Europe/Amsterdam', + 'Europe/Athens', + 'Europe/Belgrade', + 'Europe/Berlin', + 'Europe/Bratislava', + 'Europe/Brussels', + 'Europe/Bucharest', + 'Europe/Budapest', + 'Europe/Copenhagen', + 'Europe/Dublin', + 'Europe/Helsinki', + 'Europe/Istanbul', + 'Europe/Lisbon', + 'Europe/London', + 'Europe/Luxembourg', + 'Europe/Madrid', + 'Europe/Malta', + 'Europe/Moscow', + 'Europe/Oslo', + 'Europe/Paris', + 'Europe/Prague', + 'Europe/Riga', + 'Europe/Rome', + 'Europe/Stockholm', + 'Europe/Tallinn', + 'Europe/Vienna', + 'Europe/Vilnius', + 'Europe/Warsaw', + 'Europe/Zurich', + 'Pacific/Auckland', + 'Pacific/Chatham', + 'Pacific/Fakaofo', + 'Pacific/Honolulu', + 'Pacific/Norfolk', + 'US/Mountain', + ]; + + // Intl returns canonical IANA names, but TradingView uses some legacy aliases. + var CANONICAL_TO_TV = { + 'America/Denver': 'US/Mountain', + 'Asia/Ashgabat': 'Asia/Ashkhabad', + 'Asia/Almaty': 'Asia/Astana', + }; + + var userTimezone = (function () { + try { + var tz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'Etc/UTC'; + var mapped = CANONICAL_TO_TV[tz] || tz; + return TV_SUPPORTED_TIMEZONES.indexOf(mapped) !== -1 + ? mapped + : 'Etc/UTC'; + } catch (_e) { + return 'Etc/UTC'; + } + })(); window.chartWidget = new TradingView.widget({ symbol: window.currentSymbol, interval: window.currentResolution || '5', @@ -3596,6 +3715,7 @@ function initChart() { datafeed: customDatafeed, library_path: window.CONFIG.libraryUrl, locale: 'en', + timezone: userTimezone, fullscreen: false, autosize: true, theme: 'Dark', diff --git a/app/components/UI/Earn/hooks/useEarnTokens.test.ts b/app/components/UI/Earn/hooks/useEarnTokens.test.ts index 5965fc80a29..5e45f992902 100644 --- a/app/components/UI/Earn/hooks/useEarnTokens.test.ts +++ b/app/components/UI/Earn/hooks/useEarnTokens.test.ts @@ -37,6 +37,12 @@ jest.mock('../selectors/featureFlags', () => ({ mockSelectStablecoinLendingEnabledFlag(), })); +jest.mock('../../../../selectors/multichainAccounts/accounts', () => ({ + selectSelectedInternalAccountByScope: () => () => + jest.requireActual('../../../../util/test/accountsControllerTestUtils') + .internalAccount2, +})); + const MOCK_ROOT_STATE_WITH_EARN_CONTROLLER = mockEarnControllerRootState(); const MOCK_RATE = { price: 0.99, diff --git a/app/components/UI/MarketInsights/components/ArticleRow.test.tsx b/app/components/UI/MarketInsights/components/ArticleRow.test.tsx new file mode 100644 index 00000000000..421eccd0511 --- /dev/null +++ b/app/components/UI/MarketInsights/components/ArticleRow.test.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { screen, fireEvent } from '@testing-library/react-native'; +import renderWithProvider from '../../../../util/test/renderWithProvider'; +import ArticleRow from './ArticleRow'; +import type { Article } from '@metamask/ai-controllers'; + +const mockArticle: Article = { + title: 'Bitcoin ETF inflows hit record high', + url: 'https://coindesk.com/news/btc-etf-inflows', + source: 'CoinDesk', + date: '2026-03-15T10:00:00.000Z', +}; + +const mockArticleNoDate: Article = { + title: 'Ethereum upgrade expected next quarter', + url: 'https://theblock.co/news/eth-upgrade', + source: 'The Block', + date: '', +}; + +describe('ArticleRow', () => { + it('renders the article title', () => { + renderWithProvider( + , + ); + expect(screen.getByText(mockArticle.title)).toBeOnTheScreen(); + }); + + it('renders the article source', () => { + renderWithProvider( + , + ); + expect(screen.getByText(mockArticle.source)).toBeOnTheScreen(); + }); + + it('renders relative date when article has a date', () => { + renderWithProvider( + , + ); + // A separator dot is shown between source and date + expect(screen.getByText('•')).toBeOnTheScreen(); + }); + + it('does not render date separator when article has no date', () => { + renderWithProvider( + , + ); + expect(screen.queryByText('•')).toBeNull(); + }); + + it('calls onPress with the article URL when tapped', () => { + const onPress = jest.fn(); + renderWithProvider(); + fireEvent.press(screen.getByText(mockArticle.title)); + expect(onPress).toHaveBeenCalledTimes(1); + expect(onPress).toHaveBeenCalledWith(mockArticle.url); + }); + + it('renders a bottom border when isLastItem is false', () => { + const { toJSON } = renderWithProvider( + , + ); + // Tailwind compiles 'border-b border-muted' to resolved style props at render time. + const json = JSON.stringify(toJSON()); + expect(json).toContain('"borderBottomWidth":1'); + }); + + it('does not render a bottom border when isLastItem is true', () => { + const { toJSON } = renderWithProvider( + , + ); + const json = JSON.stringify(toJSON()); + expect(json).not.toContain('"borderBottomWidth":1'); + }); +}); diff --git a/app/components/UI/MarketInsights/components/ArticleRow.tsx b/app/components/UI/MarketInsights/components/ArticleRow.tsx new file mode 100644 index 00000000000..b498d869d5f --- /dev/null +++ b/app/components/UI/MarketInsights/components/ArticleRow.tsx @@ -0,0 +1,126 @@ +import React, { useCallback } from 'react'; +import { Image, Pressable } from 'react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + FontWeight, + Icon, + IconColor, + IconName, + IconSize, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import type { Article } from '@metamask/ai-controllers'; +import { + formatRelativeTime, + getFaviconUrl, +} from '../utils/marketInsightsFormatting'; + +interface ArticleRowProps { + article: Article; + onPress: (url: string) => void; + isLastItem?: boolean; +} + +/** + * A single article row used in sources bottom sheets (What's Happening and + * Market Insights). Displays the article title, favicon, source domain, + * relative date, and an export icon. Tapping the row calls onPress with the + * article URL. + */ +const ArticleRow: React.FC = ({ + article, + onPress, + isLastItem, +}) => { + const tw = useTailwind(); + + const handlePress = useCallback(() => { + onPress(article.url); + }, [onPress, article.url]); + + return ( + + tw.style( + 'flex-row items-start py-3', + !isLastItem && 'border-b border-muted', + pressed && 'opacity-70', + ) + } + > + + + + {article.title} + + + + + + + + + + + + + {article.source} + + {article.date ? ( + <> + + {'•'} + + + {formatRelativeTime(article.date, { nowLabel: 'now' })} + + + ) : null} + + + + + ); +}; + +export default ArticleRow; diff --git a/app/components/UI/MarketInsights/components/MarketInsightsTrendSourcesBottomSheet.tsx b/app/components/UI/MarketInsights/components/MarketInsightsTrendSourcesBottomSheet.tsx index 67563d194a5..8da2fee0194 100644 --- a/app/components/UI/MarketInsights/components/MarketInsightsTrendSourcesBottomSheet.tsx +++ b/app/components/UI/MarketInsights/components/MarketInsightsTrendSourcesBottomSheet.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useRef } from 'react'; -import { Image, Pressable, ScrollView } from 'react-native'; +import { Pressable, ScrollView } from 'react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { Box, @@ -25,9 +25,9 @@ import BottomSheetHeader from '../../../../component-library/components/BottomSh import { strings } from '../../../../../locales/i18n'; import { formatRelativeTime, - getFaviconUrl, getNormalizedHandle, } from '../utils/marketInsightsFormatting'; +import ArticleRow from './ArticleRow'; interface MarketInsightsTrendSourcesBottomSheetProps { isVisible: boolean; @@ -79,85 +79,12 @@ const MarketInsightsTrendSourcesBottomSheet: React.FC< const isLastItem = index === articles.length - 1 && tweets.length === 0; return ( - handleSourcePress(article.url)} - style={({ pressed }) => - tw.style( - 'flex-row items-start py-3', - !isLastItem && 'border-b border-muted', - pressed && 'opacity-70', - ) - } - > - - - - {article.title} - - - - - - - - - - - - {article.source} - - {article.date ? ( - <> - - {'•'} - - - {formatRelativeTime(article.date, { - nowLabel: 'now', - })} - - - ) : null} - - - - + article={article} + onPress={handleSourcePress} + isLastItem={isLastItem} + /> ); })} diff --git a/app/components/UI/Rewards/RewardsNavigator.test.tsx b/app/components/UI/Rewards/RewardsNavigator.test.tsx index d3f86824efe..e903bafb8ec 100644 --- a/app/components/UI/Rewards/RewardsNavigator.test.tsx +++ b/app/components/UI/Rewards/RewardsNavigator.test.tsx @@ -200,6 +200,10 @@ const mockSetOptions = jest.fn(); const mockSetParams = jest.fn(); const mockIsFocused = jest.fn(); const mockReactReduxDispatch = jest.fn(); +const mockUseNavigationState = jest.fn( + (selector: (state: unknown) => unknown): unknown => + selector({ routes: [{}], index: 0 }), +); jest.mock('@react-navigation/native', () => { const actual = jest.requireActual('@react-navigation/native'); @@ -211,6 +215,8 @@ jest.mock('@react-navigation/native', () => { setParams: mockSetParams, }), useIsFocused: () => mockIsFocused(), + useNavigationState: (selector: (state: unknown) => unknown) => + mockUseNavigationState(selector), }; }); @@ -249,6 +255,37 @@ jest.mock('./hooks/useReferralDetails', () => ({ }), })); +// Mock useRewardsNotificationsNudge hook +const mockShowEnableNotificationsNudge = jest.fn(() => false); +const mockCloseEnableNotificationsNudge = jest.fn(); +jest.mock('./hooks/useRewardsNotificationsNudge', () => ({ + useRewardsNotificationsNudge: jest.fn(() => ({ + areNotificationsEnabled: true, + canPromptToEnableNotifications: false, + shouldPromptToEnableNotifications: false, + showEnableNotificationsNudge: mockShowEnableNotificationsNudge, + closeEnableNotificationsNudge: mockCloseEnableNotificationsNudge, + runAfterNotificationsEnabled: jest.fn(), + })), +})); + +// Mock useRewardsToast hook +const mockNavigatorShowToast = jest.fn(); +const mockSuccessToast = jest.fn(() => ({ variant: 'success' })); +jest.mock('./hooks/useRewardsToast', () => ({ + __esModule: true, + default: jest.fn(() => ({ + showToast: mockNavigatorShowToast, + RewardsToastOptions: { + success: mockSuccessToast, + error: jest.fn(), + loading: jest.fn(), + entriesClosed: jest.fn(), + enableNotificationsNudge: jest.fn(), + }, + })), +})); + // Mock useRewardsVersionGuard hook jest.mock('./hooks/useRewardsVersionGuard', () => ({ __esModule: true, @@ -280,6 +317,7 @@ import { import { setPendingDeeplink } from '../../../reducers/rewards'; import { useSeasonStatus } from './hooks/useSeasonStatus'; import { useGeoRewardsMetadata } from './hooks/useGeoRewardsMetadata'; +import { useRewardsNotificationsNudge } from './hooks/useRewardsNotificationsNudge'; const mockSelectRewardsSubscriptionId = selectRewardsSubscriptionId as jest.MockedFunction< @@ -301,6 +339,10 @@ const mockUseSeasonStatus = useSeasonStatus as jest.MockedFunction< const mockUseGeoRewardsMetadata = useGeoRewardsMetadata as jest.MockedFunction< typeof useGeoRewardsMetadata >; +const mockUseRewardsNotificationsNudge = + useRewardsNotificationsNudge as jest.MockedFunction< + typeof useRewardsNotificationsNudge + >; describe('RewardsNavigator', () => { let store: ReturnType; @@ -813,4 +855,355 @@ describe('RewardsNavigator', () => { }); }); }); + + describe('Notification nudge behavior', () => { + beforeEach(() => { + mockSelectRewardsSubscriptionId.mockReturnValue('test-subscription-id'); + mockSelectPendingDeeplink.mockReturnValue(null); + mockSelectIsRewardsVersionBlocked.mockReturnValue(false); + mockUseSeasonStatus.mockReturnValue({ fetchSeasonStatus: jest.fn() }); + mockUseGeoRewardsMetadata.mockReturnValue({ + fetchGeoRewardsMetadata: jest.fn(), + }); + }); + + it('does not show nudge when canPromptToEnableNotifications is false', async () => { + mockUseRewardsNotificationsNudge.mockReturnValue({ + areNotificationsEnabled: false, + canPromptToEnableNotifications: false, + shouldPromptToEnableNotifications: false, + showEnableNotificationsNudge: mockShowEnableNotificationsNudge, + closeEnableNotificationsNudge: mockCloseEnableNotificationsNudge, + runAfterNotificationsEnabled: jest.fn(), + }); + mockUseNavigationState.mockImplementation( + (selector: (state: unknown) => unknown) => + selector({ + routes: [ + { + state: { + routes: [{ name: Routes.REWARDS_CAMPAIGNS_VIEW }], + index: 0, + }, + }, + ], + index: 0, + }), + ); + + renderWithNavigation(); + + await waitFor(() => expect(true).toBe(true)); + expect(mockShowEnableNotificationsNudge).not.toHaveBeenCalled(); + }); + + it('does not show nudge when notifications are already enabled', async () => { + mockUseRewardsNotificationsNudge.mockReturnValue({ + areNotificationsEnabled: true, + canPromptToEnableNotifications: true, + shouldPromptToEnableNotifications: false, + showEnableNotificationsNudge: mockShowEnableNotificationsNudge, + closeEnableNotificationsNudge: mockCloseEnableNotificationsNudge, + runAfterNotificationsEnabled: jest.fn(), + }); + mockUseNavigationState.mockImplementation( + (selector: (state: unknown) => unknown) => + selector({ + routes: [ + { + state: { + routes: [{ name: Routes.REWARDS_CAMPAIGNS_VIEW }], + index: 0, + }, + }, + ], + index: 0, + }), + ); + + renderWithNavigation(); + + await waitFor(() => expect(true).toBe(true)); + expect(mockShowEnableNotificationsNudge).not.toHaveBeenCalled(); + }); + + it('does not show nudge when not on a campaign route', async () => { + mockUseRewardsNotificationsNudge.mockReturnValue({ + areNotificationsEnabled: false, + canPromptToEnableNotifications: true, + shouldPromptToEnableNotifications: true, + showEnableNotificationsNudge: mockShowEnableNotificationsNudge, + closeEnableNotificationsNudge: mockCloseEnableNotificationsNudge, + runAfterNotificationsEnabled: jest.fn(), + }); + mockUseNavigationState.mockImplementation( + (selector: (state: unknown) => unknown) => + selector({ + routes: [ + { + state: { + routes: [{ name: Routes.REWARDS_DASHBOARD }], + index: 0, + }, + }, + ], + index: 0, + }), + ); + + renderWithNavigation(); + + await waitFor(() => expect(true).toBe(true)); + expect(mockShowEnableNotificationsNudge).not.toHaveBeenCalled(); + }); + + it('does not show nudge when showEnableNotificationsNudge returns false', async () => { + mockUseRewardsNotificationsNudge.mockReturnValue({ + areNotificationsEnabled: false, + canPromptToEnableNotifications: true, + shouldPromptToEnableNotifications: true, + showEnableNotificationsNudge: mockShowEnableNotificationsNudge, + closeEnableNotificationsNudge: mockCloseEnableNotificationsNudge, + runAfterNotificationsEnabled: jest.fn(), + }); + mockShowEnableNotificationsNudge.mockReturnValue(false); + mockUseNavigationState.mockImplementation( + (selector: (state: unknown) => unknown) => + selector({ + routes: [ + { + state: { + routes: [{ name: Routes.REWARDS_CAMPAIGNS_VIEW }], + index: 0, + }, + }, + ], + index: 0, + }), + ); + + renderWithNavigation(); + + await waitFor(() => { + expect(mockShowEnableNotificationsNudge).toHaveBeenCalledTimes(1); + }); + expect(mockCloseEnableNotificationsNudge).not.toHaveBeenCalled(); + }); + + it('shows nudge on campaign route and closes it when navigating away', async () => { + mockUseRewardsNotificationsNudge.mockReturnValue({ + areNotificationsEnabled: false, + canPromptToEnableNotifications: true, + shouldPromptToEnableNotifications: true, + showEnableNotificationsNudge: mockShowEnableNotificationsNudge, + closeEnableNotificationsNudge: mockCloseEnableNotificationsNudge, + runAfterNotificationsEnabled: jest.fn(), + }); + mockShowEnableNotificationsNudge.mockReturnValue(true); + mockUseNavigationState.mockImplementation( + (selector: (state: unknown) => unknown) => + selector({ + routes: [ + { + state: { + routes: [{ name: Routes.REWARDS_CAMPAIGNS_VIEW }], + index: 0, + }, + }, + ], + index: 0, + }), + ); + + const { rerender } = renderWithNavigation(); + + await waitFor(() => { + expect(mockShowEnableNotificationsNudge).toHaveBeenCalledTimes(1); + }); + + // Navigate away to a non-campaign route + mockUseNavigationState.mockImplementation( + (selector: (state: unknown) => unknown) => + selector({ + routes: [ + { + state: { + routes: [{ name: Routes.REWARDS_DASHBOARD }], + index: 0, + }, + }, + ], + index: 0, + }), + ); + + await act(async () => { + rerender(buildNavWrapper()); + }); + + expect(mockCloseEnableNotificationsNudge).toHaveBeenCalledTimes(1); + }); + + it('does not show nudge again when session flag is already set', async () => { + mockUseRewardsNotificationsNudge.mockReturnValue({ + areNotificationsEnabled: false, + canPromptToEnableNotifications: true, + shouldPromptToEnableNotifications: true, + showEnableNotificationsNudge: mockShowEnableNotificationsNudge, + closeEnableNotificationsNudge: mockCloseEnableNotificationsNudge, + runAfterNotificationsEnabled: jest.fn(), + }); + mockUseNavigationState.mockImplementation( + (selector: (state: unknown) => unknown) => + selector({ + routes: [ + { + state: { + routes: [{ name: Routes.REWARDS_CAMPAIGNS_VIEW }], + index: 0, + }, + }, + ], + index: 0, + }), + ); + + renderWithNavigation(); + + await waitFor(() => expect(true).toBe(true)); + // sessionNotificationsNudgeShown was set true by the previous test + expect(mockShowEnableNotificationsNudge).not.toHaveBeenCalled(); + }); + + it('evaluates route conditions for ONDO campaign route', async () => { + mockUseRewardsNotificationsNudge.mockReturnValue({ + areNotificationsEnabled: false, + canPromptToEnableNotifications: true, + shouldPromptToEnableNotifications: true, + showEnableNotificationsNudge: mockShowEnableNotificationsNudge, + closeEnableNotificationsNudge: mockCloseEnableNotificationsNudge, + runAfterNotificationsEnabled: jest.fn(), + }); + mockUseNavigationState.mockImplementation( + (selector: (state: unknown) => unknown) => + selector({ + routes: [ + { + state: { + routes: [{ name: Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW }], + index: 0, + }, + }, + ], + index: 0, + }), + ); + + renderWithNavigation(); + + await waitFor(() => expect(true).toBe(true)); + // session flag already set — nudge not reshown, but route conditions were evaluated + expect(mockShowEnableNotificationsNudge).not.toHaveBeenCalled(); + }); + + it('evaluates route conditions for SeasonOne campaign route', async () => { + mockUseRewardsNotificationsNudge.mockReturnValue({ + areNotificationsEnabled: false, + canPromptToEnableNotifications: true, + shouldPromptToEnableNotifications: true, + showEnableNotificationsNudge: mockShowEnableNotificationsNudge, + closeEnableNotificationsNudge: mockCloseEnableNotificationsNudge, + runAfterNotificationsEnabled: jest.fn(), + }); + mockUseNavigationState.mockImplementation( + (selector: (state: unknown) => unknown) => + selector({ + routes: [ + { + state: { + routes: [ + { + name: Routes.REWARDS_SEASON_ONE_CAMPAIGN_DETAILS_VIEW, + }, + ], + index: 0, + }, + }, + ], + index: 0, + }), + ); + + renderWithNavigation(); + + await waitFor(() => expect(true).toBe(true)); + expect(mockShowEnableNotificationsNudge).not.toHaveBeenCalled(); + }); + + it('evaluates route conditions for Perps Trading campaign route', async () => { + mockUseRewardsNotificationsNudge.mockReturnValue({ + areNotificationsEnabled: false, + canPromptToEnableNotifications: true, + shouldPromptToEnableNotifications: true, + showEnableNotificationsNudge: mockShowEnableNotificationsNudge, + closeEnableNotificationsNudge: mockCloseEnableNotificationsNudge, + runAfterNotificationsEnabled: jest.fn(), + }); + mockUseNavigationState.mockImplementation( + (selector: (state: unknown) => unknown) => + selector({ + routes: [ + { + state: { + routes: [ + { + name: Routes.REWARDS_PERPS_TRADING_CAMPAIGN_DETAILS_VIEW, + }, + ], + index: 0, + }, + }, + ], + index: 0, + }), + ); + + renderWithNavigation(); + + await waitFor(() => expect(true).toBe(true)); + expect(mockShowEnableNotificationsNudge).not.toHaveBeenCalled(); + }); + + it('calls success toast when onNotificationsEnabled callback fires', async () => { + let capturedCallback: (() => void) | undefined; + mockUseRewardsNotificationsNudge.mockImplementation( + (options: { onNotificationsEnabled?: () => void } = {}) => { + capturedCallback = options.onNotificationsEnabled; + return { + areNotificationsEnabled: false, + canPromptToEnableNotifications: true, + shouldPromptToEnableNotifications: true, + showEnableNotificationsNudge: mockShowEnableNotificationsNudge, + closeEnableNotificationsNudge: mockCloseEnableNotificationsNudge, + runAfterNotificationsEnabled: jest.fn(), + }; + }, + ); + + renderWithNavigation(); + + await waitFor(() => { + expect(capturedCallback).toBeDefined(); + }); + + act(() => { + capturedCallback?.(); + }); + + expect(mockNavigatorShowToast).toHaveBeenCalledTimes(1); + expect(mockSuccessToast).toHaveBeenCalledWith( + 'rewards.notifications_nudge.success', + ); + }); + }); }); diff --git a/app/components/UI/Rewards/RewardsNavigator.tsx b/app/components/UI/Rewards/RewardsNavigator.tsx index 7371d347cfb..f9aabffa47a 100644 --- a/app/components/UI/Rewards/RewardsNavigator.tsx +++ b/app/components/UI/Rewards/RewardsNavigator.tsx @@ -27,13 +27,18 @@ import { } from '../../../reducers/rewards/selectors'; import { setPendingDeeplink } from '../../../reducers/rewards'; import { useCandidateSubscriptionId } from './hooks/useCandidateSubscriptionId'; -import { useNavigation } from '@react-navigation/native'; +import { useNavigation, useNavigationState } from '@react-navigation/native'; import { useTheme } from '../../../util/theme'; import useRewardsVersionGuard from './hooks/useRewardsVersionGuard'; import RewardsUpdateRequired from './components/RewardsUpdateRequired/RewardsUpdateRequired'; import { useSeasonStatus } from './hooks/useSeasonStatus'; import { useGeoRewardsMetadata } from './hooks/useGeoRewardsMetadata'; import { useReferralDetails } from './hooks/useReferralDetails'; +import { useRewardsNotificationsNudge } from './hooks/useRewardsNotificationsNudge'; +import useRewardsToast from './hooks/useRewardsToast'; +import { strings } from '../../../../locales/i18n'; + +let sessionNotificationsNudgeShown = false; const Stack = createStackNavigator(); @@ -69,6 +74,83 @@ const RewardsNavigator: React.FC = () => { // Fetch referral details so referral code is available across all rewards screens useReferralDetails(); + const { showToast, RewardsToastOptions } = useRewardsToast(); + + const nudgeToastActiveRef = useRef(false); + + const { + areNotificationsEnabled, + canPromptToEnableNotifications, + showEnableNotificationsNudge, + closeEnableNotificationsNudge, + } = useRewardsNotificationsNudge({ + onNotificationsEnabled: () => { + showToast( + RewardsToastOptions.success( + strings('rewards.notifications_nudge.success'), + ), + ); + }, + }); + + const activeRewardsRoute = useNavigationState((state) => { + const currentTabRoute = state.routes[state.index]; + const stackState = currentTabRoute?.state; + const stackIndex = + typeof stackState?.index === 'number' ? stackState.index : 0; + return stackState?.routes[stackIndex]?.name; + }); + + useEffect(() => { + if (!canPromptToEnableNotifications) { + return; + } + if (areNotificationsEnabled) { + return; + } + + const isOnCampaignRoute = + activeRewardsRoute === Routes.REWARDS_CAMPAIGNS_VIEW || + activeRewardsRoute === Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW || + activeRewardsRoute === Routes.REWARDS_SEASON_ONE_CAMPAIGN_DETAILS_VIEW || + activeRewardsRoute === Routes.REWARDS_PERPS_TRADING_CAMPAIGN_DETAILS_VIEW; + + if (!isOnCampaignRoute) { + // Explicitly close the nudge when navigating away — don't rely solely on + // the effect cleanup, which can miss fires when the nested navigator state + // change doesn't propagate up to useNavigationState in time. + if (nudgeToastActiveRef.current) { + nudgeToastActiveRef.current = false; + closeEnableNotificationsNudge(); + } + return; + } + + if (sessionNotificationsNudgeShown) { + return; + } + + const didShowNudge = showEnableNotificationsNudge(); + if (!didShowNudge) { + return; + } + sessionNotificationsNudgeShown = true; + nudgeToastActiveRef.current = true; + + return () => { + if (nudgeToastActiveRef.current) { + nudgeToastActiveRef.current = false; + closeEnableNotificationsNudge(); + } + }; + }, [ + activeRewardsRoute, + areNotificationsEnabled, + canPromptToEnableNotifications, + closeEnableNotificationsNudge, + showEnableNotificationsNudge, + ]); + // Determine initial route - always start with onboarding intro step initially const getInitialRoute = () => { // If user has already opted in and has a valid subscription candidate ID, go to dashboard diff --git a/app/components/UI/Rewards/Views/OndoCampaignDetailsView.test.tsx b/app/components/UI/Rewards/Views/OndoCampaignDetailsView.test.tsx index 0093688e435..0e5b078b1d1 100644 --- a/app/components/UI/Rewards/Views/OndoCampaignDetailsView.test.tsx +++ b/app/components/UI/Rewards/Views/OndoCampaignDetailsView.test.tsx @@ -1,7 +1,8 @@ import React from 'react'; -import { render, fireEvent } from '@testing-library/react-native'; +import { render, fireEvent, act } from '@testing-library/react-native'; import OndoCampaignDetailsView, { CAMPAIGN_DETAILS_TEST_IDS, + resetOndoCampaignDetailsSessionAutoNavigationForTests, } from './OndoCampaignDetailsView'; import { ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS } from '../components/Campaigns/OndoCampaignStatsSummary'; import { ONDO_PRIZE_POOL_TEST_IDS } from '../components/Campaigns/OndoPrizePool'; @@ -19,7 +20,12 @@ import { useGetOndoPortfolioPosition } from '../hooks/useGetOndoPortfolioPositio import { useGetOndoCampaignDeposits } from '../hooks/useGetOndoCampaignDeposits'; import { useOndoCampaignParticipantOutcome } from '../hooks/useOndoCampaignParticipantOutcome'; import Routes from '../../../../constants/navigation/Routes'; - +import { useSelector } from 'react-redux'; +import { selectReferralCode } from '../../../../reducers/rewards/selectors'; +import { + selectIsMetamaskNotificationsEnabled, + selectIsMetaMaskPushNotificationsEnabled, +} from '../../../../selectors/notifications'; const mockGoBack = jest.fn(); const mockNavigate = jest.fn(); const mockRouteState: { params: { campaignId?: string } } = { @@ -171,18 +177,6 @@ jest.mock('../components/Campaigns/OndoCampaignStatsSummary', () => { }; }); -jest.mock('../hooks/useRewardsToast', () => ({ - __esModule: true, - default: () => ({ - showToast: jest.fn(), - RewardsToastOptions: { - success: jest.fn(), - error: jest.fn(), - entriesClosed: jest.fn(() => ({ variant: 'icon' })), - }, - }), -})); - jest.mock('../hooks/useCampaignGeoRestriction', () => ({ __esModule: true, default: () => ({ isGeoRestricted: false, isGeoLoading: false }), @@ -315,7 +309,7 @@ jest.mock('../components/Campaigns/OndoPrizePool', () => { }); jest.mock('react-redux', () => ({ - useSelector: jest.fn(() => null), + useSelector: jest.fn(), })); const mockIsTokenTradingOpen = jest.fn(() => true); @@ -511,6 +505,7 @@ jest.mock('../../../../../locales/i18n', () => ({ 'rewards.campaign_details.competition_closed_description': 'Entries are now closed', 'rewards.ondo_campaign_portfolio.view_activity': 'View activity', + 'rewards.notifications_nudge.turn_on_button': 'Turn on', }; return translations[key] || key; }, @@ -554,6 +549,19 @@ const hookDefaults = { describe('OndoCampaignDetailsView', () => { beforeEach(() => { jest.clearAllMocks(); + resetOndoCampaignDetailsSessionAutoNavigationForTests(); + (useSelector as jest.Mock).mockImplementation((selector: unknown) => { + if (selector === selectReferralCode) { + return null; + } + if (selector === selectIsMetamaskNotificationsEnabled) { + return true; + } + if (selector === selectIsMetaMaskPushNotificationsEnabled) { + return true; + } + return null; + }); mockIsTokenTradingOpen.mockReturnValue(true); mockOndoCampaignStatsSummary.mockReset(); mockUseRewardCampaigns.mockReturnValue(hookDefaults); diff --git a/app/components/UI/Rewards/Views/OndoCampaignDetailsView.tsx b/app/components/UI/Rewards/Views/OndoCampaignDetailsView.tsx index 9fd0cd8f975..d4370634da9 100644 --- a/app/components/UI/Rewards/Views/OndoCampaignDetailsView.tsx +++ b/app/components/UI/Rewards/Views/OndoCampaignDetailsView.tsx @@ -81,7 +81,6 @@ export const CAMPAIGN_DETAILS_TEST_IDS = { const sessionUpcomingRedirectCampaignIds = new Set(); const sessionWinningViewAutoNavCampaignIds = new Set(); - export function resetOndoCampaignDetailsSessionAutoNavigationForTests(): void { sessionUpcomingRedirectCampaignIds.clear(); sessionWinningViewAutoNavCampaignIds.clear(); diff --git a/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx b/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx index 1c5b6ff9fdb..98fb1ab3dfa 100644 --- a/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx +++ b/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx @@ -91,26 +91,6 @@ const mockAddProperties = jest.fn(() => ({ build: mockBuild })); jest.mock('../../../hooks/useAnalytics/useAnalytics'); -// Mock Toast component -jest.mock('../../../../component-library/components/Toast', () => { - const ReactActual = jest.requireActual('react'); - return { - __esModule: true, - default: ReactActual.forwardRef( - ( - _props: Record, - ref: React.Ref<{ showToast: jest.Mock }>, - ) => { - ReactActual.useImperativeHandle(ref, () => ({ - showToast: jest.fn(), - closeToast: jest.fn(), - })); - return ReactActual.createElement(ReactActual.Fragment, null, 'Toast'); - }, - ), - }; -}); - // Mock i18n jest.mock('../../../../../locales/i18n', () => ({ strings: jest.fn((key: string) => { diff --git a/app/components/UI/Rewards/Views/RewardsDashboard.tsx b/app/components/UI/Rewards/Views/RewardsDashboard.tsx index bee49e20a23..ded32bc79da 100644 --- a/app/components/UI/Rewards/Views/RewardsDashboard.tsx +++ b/app/components/UI/Rewards/Views/RewardsDashboard.tsx @@ -22,8 +22,6 @@ import { } from '../hooks/useRewardDashboardModals'; import { useBulkLinkState } from '../hooks/useBulkLinkState'; import { useOndoOutcomeToast } from '../hooks/useOndoOutcomeToast'; -import Toast from '../../../../component-library/components/Toast'; -import { ToastRef } from '../../../../component-library/components/Toast/Toast.types'; import { MetaMetricsEvents } from '../../../../core/Analytics'; import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; import useTrackRewardsPageView from '../hooks/useTrackRewardsPageView'; @@ -36,7 +34,6 @@ import { ScrollView } from 'react-native'; const RewardsDashboard: React.FC = () => { const tw = useTailwind(); const navigation = useNavigation(); - const toastRef = useRef(null); const subscriptionId = useSelector(selectRewardsSubscriptionId); const activeTab = useSelector(selectActiveTab); const { trackEvent, createEventBuilder } = useAnalytics(); @@ -217,7 +214,6 @@ const RewardsDashboard: React.FC = () => { - ); }; diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignReminder.test.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignReminder.test.tsx index 3932b73c4fa..d9b27232060 100644 --- a/app/components/UI/Rewards/components/Campaigns/CampaignReminder.test.tsx +++ b/app/components/UI/Rewards/components/Campaigns/CampaignReminder.test.tsx @@ -2,17 +2,37 @@ import React from 'react'; import { render, fireEvent, waitFor, act } from '@testing-library/react-native'; import { useSelector } from 'react-redux'; import CampaignReminder from './CampaignReminder'; -import { reminderStorageKeyForComposite } from '../../hooks/useCampaignReminderSubscriptions'; +import { + buildCampaignReminderCompositeKey, + reminderStorageKeyForComposite, +} from '../../hooks/useCampaignReminderActions'; import { type CampaignDto, CampaignType, } from '../../../../../core/Engine/controllers/rewards-controller/types'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; import { selectRewardsSubscriptionId } from '../../../../../selectors/rewards'; +import { + selectIsMetamaskNotificationsEnabled, + selectIsMetaMaskPushNotificationsEnabled, +} from '../../../../../selectors/notifications'; +import { isNotificationsFeatureEnabled } from '../../../../../util/notifications/constants'; const mockTrackEvent = jest.fn(); const mockCreateEventBuilder = jest.fn(); const mockShowToast = jest.fn(); +const mockEnableNotifications = jest.fn(); +const mockEnableNotificationsNudge = jest.fn( + (linkButtonOptions: { label: string; onPress: () => Promise }) => ({ + variant: 'Plain', + hasNoTimeout: true, + linkButtonOptions, + closeButtonOptions: { + onPress: jest.fn(), + }, + }), +); +let mockEnableNotificationsLoading = false; const TEST_REWARDS_SUBSCRIPTION_ID = 'test-rewards-sub-id'; @@ -58,11 +78,37 @@ jest.mock('../../hooks/useRewardsToast', () => ({ title, subtitle, })), + enableNotificationsNudge: mockEnableNotificationsNudge, + loading: jest.fn((title: string, subtitle?: string) => ({ + variant: 'loading', + title, + subtitle, + })), entriesClosed: jest.fn(), }, })), })); +jest.mock('../../../../../util/notifications/hooks/useNotifications', () => ({ + useEnableNotifications: jest.fn(() => ({ + enableNotifications: mockEnableNotifications, + loading: mockEnableNotificationsLoading, + })), +})); + +jest.mock('../../../../../util/notifications/constants', () => ({ + isNotificationsFeatureEnabled: jest.fn(() => true), +})); + +jest.mock( + '../../../../../util/notifications/services/NotificationService', + () => ({ + __esModule: true, + default: { openSystemSettings: jest.fn() }, + getPushPermission: jest.fn().mockResolvedValue('authorized'), + }), +); + jest.mock('../../../../../images/rewards/notification.svg', () => { const ReactActual = jest.requireActual('react'); const { View } = jest.requireActual('react-native'); @@ -89,6 +135,12 @@ jest.mock('../../../../../../locales/i18n', () => ({ 'rewards.campaign.notify_me': 'Notify me', 'rewards.campaign.remind_me_success_toast': 'We will notify you.', 'rewards.campaign.remind_me_save_error': 'Save failed.', + 'rewards.notifications_nudge.turn_on_button': 'Turn on', + 'rewards.notifications_nudge.loading': 'Enabling notifications...', + 'rewards.notifications_nudge.loading_description': + 'This may take a moment.', + 'rewards.notifications_nudge.enable_error': + 'Failed to enable notifications', }; return translations[key] || key; }, @@ -108,17 +160,29 @@ const createTestCampaign = (overrides = {}): CampaignDto => ({ ...overrides, }); +function mockSelectors({ notificationsEnabled = true } = {}) { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectRewardsSubscriptionId) { + return TEST_REWARDS_SUBSCRIPTION_ID; + } + if ( + selector === selectIsMetamaskNotificationsEnabled || + selector === selectIsMetaMaskPushNotificationsEnabled + ) { + return notificationsEnabled; + } + return undefined; + }); +} + describe('CampaignReminder', () => { beforeEach(() => { jest.clearAllMocks(); + mockEnableNotifications.mockResolvedValue(undefined); + mockEnableNotificationsLoading = false; mockGetItemSync.mockReturnValue(null); mockSetItem.mockResolvedValue(undefined); - mockUseSelector.mockImplementation((selector) => { - if (selector === selectRewardsSubscriptionId) { - return TEST_REWARDS_SUBSCRIPTION_ID; - } - return undefined; - }); + mockSelectors(); mockCreateEventBuilder.mockImplementation(() => { const builder = { addProperties: jest.fn(), @@ -127,6 +191,7 @@ describe('CampaignReminder', () => { (builder.addProperties as jest.Mock).mockReturnValue(builder); return builder; }); + (isNotificationsFeatureEnabled as jest.Mock).mockReturnValue(true); }); it('renders up next label and campaign name', async () => { @@ -159,10 +224,12 @@ describe('CampaignReminder', () => { fireEvent.press(getByTestId('campaign-reminder-notify-cr-analytics')); }); + const compositeKey = buildCampaignReminderCompositeKey( + TEST_REWARDS_SUBSCRIPTION_ID, + 'cr-analytics', + ); expect(mockSetItem).toHaveBeenCalledWith( - reminderStorageKeyForComposite( - `${TEST_REWARDS_SUBSCRIPTION_ID}:cr-analytics`, - ), + reminderStorageKeyForComposite(compositeKey), '1', ); expect(mockCreateEventBuilder).toHaveBeenCalledWith( @@ -171,4 +238,108 @@ describe('CampaignReminder', () => { expect(mockTrackEvent).toHaveBeenCalledTimes(1); expect(mockShowToast).toHaveBeenCalledTimes(1); }); + + it('prompts for notifications and tracks only after push notifications are enabled', async () => { + let notificationsEnabled = false; + mockSelectors({ notificationsEnabled }); + const campaign = createTestCampaign({ id: 'cr-notifications' }); + const { getByTestId, rerender } = render( + , + ); + + await waitFor(() => { + expect( + getByTestId('campaign-reminder-notify-cr-notifications'), + ).toBeOnTheScreen(); + }); + + await act(async () => { + fireEvent.press(getByTestId('campaign-reminder-notify-cr-notifications')); + }); + + expect(mockEnableNotificationsNudge).toHaveBeenCalledWith( + expect.objectContaining({ + label: 'Turn on', + onPress: expect.any(Function), + }), + ); + expect(mockSetItem).not.toHaveBeenCalled(); + expect(mockTrackEvent).not.toHaveBeenCalled(); + + const linkButtonOptions = mockEnableNotificationsNudge.mock.calls[0][0] as { + onPress: () => Promise; + }; + await act(async () => { + await linkButtonOptions.onPress(); + }); + expect(mockEnableNotifications).toHaveBeenCalledTimes(1); + + notificationsEnabled = true; + mockSelectors({ notificationsEnabled }); + rerender(); + + await waitFor(() => { + const compositeKey = buildCampaignReminderCompositeKey( + TEST_REWARDS_SUBSCRIPTION_ID, + 'cr-notifications', + ); + expect(mockSetItem).toHaveBeenCalledWith( + reminderStorageKeyForComposite(compositeKey), + '1', + ); + }); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.REWARDS_CAMPAIGN_REMINDER_SUBSCRIBED, + ); + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + }); + + it('does not show Notify me when the notifications feature flag is off', async () => { + (isNotificationsFeatureEnabled as jest.Mock).mockReturnValue(false); + const campaign = createTestCampaign({ id: 'cr-feature-off' }); + const { queryByTestId } = render(); + + await waitFor(() => { + expect( + queryByTestId('campaign-reminder-notify-cr-feature-off'), + ).toBeNull(); + }); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + + it('shows Notify me CTA when notifications are disabled even if reminder was already stored', async () => { + mockSelectors({ notificationsEnabled: false }); + mockGetItemSync.mockReturnValue('1'); + const campaign = createTestCampaign({ id: 'cr-re-subscribe' }); + const { getByTestId } = render(); + + await waitFor(() => { + expect( + getByTestId('campaign-reminder-notify-cr-re-subscribe'), + ).toBeOnTheScreen(); + }); + }); + + it('does not show Notify me CTA when notifications are enabled and reminder is already stored', async () => { + mockSelectors({ notificationsEnabled: true }); + mockGetItemSync.mockReturnValue('1'); + const campaign = createTestCampaign({ id: 'cr-already-stored' }); + const { queryByTestId } = render(); + + await waitFor(() => { + expect( + queryByTestId('campaign-reminder-notify-cr-already-stored'), + ).toBeNull(); + }); + }); +}); + +describe('campaign reminder storage helpers', () => { + describe('reminderStorageKeyForComposite', () => { + it('prefixes composite key for isolated MMKV rows', () => { + expect(reminderStorageKeyForComposite('sub-1:camp-2')).toBe( + 'rewards_campaign_reminder_subscribed::sub-1:camp-2', + ); + }); + }); }); diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignTile.test.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignTile.test.tsx index 9b1037a7254..e99df8af487 100644 --- a/app/components/UI/Rewards/components/Campaigns/CampaignTile.test.tsx +++ b/app/components/UI/Rewards/components/Campaigns/CampaignTile.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { render, fireEvent, waitFor, act } from '@testing-library/react-native'; import { useSelector } from 'react-redux'; import CampaignTile from './CampaignTile'; -import { reminderStorageKeyForComposite } from '../../hooks/useCampaignReminderSubscriptions'; +import { reminderStorageKeyForComposite } from '../../hooks/useCampaignReminderActions'; import { type CampaignDto, CampaignType, @@ -15,11 +15,28 @@ import useGetCampaignParticipantStatus from '../../hooks/useGetCampaignParticipa import Routes from '../../../../../constants/navigation/Routes'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; import { selectRewardsSubscriptionId } from '../../../../../selectors/rewards'; +import { + selectIsMetamaskNotificationsEnabled, + selectIsMetaMaskPushNotificationsEnabled, +} from '../../../../../selectors/notifications'; +import { isNotificationsFeatureEnabled } from '../../../../../util/notifications/constants'; const mockNavigate = jest.fn(); const mockTrackEvent = jest.fn(); const mockCreateEventBuilder = jest.fn(); const mockShowToast = jest.fn(); +const mockEnableNotifications = jest.fn(); +const mockEnableNotificationsNudge = jest.fn( + (linkButtonOptions: { label: string; onPress: () => Promise }) => ({ + variant: 'Plain', + hasNoTimeout: true, + linkButtonOptions, + closeButtonOptions: { + onPress: jest.fn(), + }, + }), +); +let mockEnableNotificationsLoading = false; const TEST_REWARDS_SUBSCRIPTION_ID = 'test-rewards-sub-id'; @@ -65,10 +82,28 @@ jest.mock('../../hooks/useRewardsToast', () => ({ title, subtitle, })), + enableNotificationsNudge: mockEnableNotificationsNudge, entriesClosed: jest.fn(), + loading: jest.fn((title: string, subtitle?: string) => ({ + variant: 'loading', + title, + subtitle, + })), }, })), })); + +jest.mock('../../../../../util/notifications/hooks/useNotifications', () => ({ + useEnableNotifications: jest.fn(() => ({ + enableNotifications: mockEnableNotifications, + loading: mockEnableNotificationsLoading, + })), +})); + +jest.mock('../../../../../util/notifications/constants', () => ({ + isNotificationsFeatureEnabled: jest.fn(() => true), +})); + jest.mock('@react-navigation/native', () => ({ useNavigation: jest.fn().mockReturnValue({ navigate: (...args: unknown[]) => mockNavigate(...args), @@ -117,6 +152,7 @@ jest.mock('../../../../../../locales/i18n', () => ({ 'rewards.campaign.notify_me': 'Notify me', 'rewards.campaign.remind_me_success_toast': 'We will notify you.', 'rewards.campaign.remind_me_save_error': 'Save failed.', + 'rewards.notifications_nudge.turn_on_button': 'Turn on', }; return translations[key] || key; }, @@ -148,12 +184,20 @@ function setupParticipantStatus(optedIn: boolean) { describe('CampaignTile', () => { beforeEach(() => { jest.clearAllMocks(); + mockEnableNotifications.mockResolvedValue(undefined); + mockEnableNotificationsLoading = false; mockGetItemSync.mockReturnValue(null); mockSetItem.mockResolvedValue(undefined); mockUseSelector.mockImplementation((selector) => { if (selector === selectRewardsSubscriptionId) { return TEST_REWARDS_SUBSCRIPTION_ID; } + if ( + selector === selectIsMetamaskNotificationsEnabled || + selector === selectIsMetaMaskPushNotificationsEnabled + ) { + return true; + } return undefined; }); mockCreateEventBuilder.mockImplementation(() => { @@ -172,6 +216,7 @@ describe('CampaignTile', () => { dateLabelIcon: 'Clock', }); (isCampaignTypeSupported as jest.Mock).mockReturnValue(true); + (isNotificationsFeatureEnabled as jest.Mock).mockReturnValue(true); mockUseGetCampaignParticipantStatus.mockReturnValue({ status: null, isLoading: false, @@ -662,6 +707,172 @@ describe('CampaignTile', () => { expect(mockShowToast).toHaveBeenCalledTimes(1); }); + it('prompts for notifications and tracks only after push notifications are enabled', async () => { + let notificationsEnabled = false; + mockUseSelector.mockImplementation((selector) => { + if (selector === selectRewardsSubscriptionId) { + return TEST_REWARDS_SUBSCRIPTION_ID; + } + if ( + selector === selectIsMetamaskNotificationsEnabled || + selector === selectIsMetaMaskPushNotificationsEnabled + ) { + return notificationsEnabled; + } + return undefined; + }); + (getCampaignStatusInfo as jest.Mock).mockReturnValue({ + status: 'upcoming', + statusLabel: 'Coming soon', + dateLabel: 'Starts June 1', + dateLabelIcon: 'Speed', + }); + const campaign = createTestCampaign({ + id: 'camp-remind-notifications', + type: CampaignType.PERPS_TRADING, + startDate: '2028-07-15T00:00:00.000Z', + }); + + const { getByTestId, rerender } = render( + , + ); + await waitFor(() => { + expect( + getByTestId('campaign-tile-remind-me-camp-remind-notifications'), + ).toBeTruthy(); + }); + + await act(async () => { + fireEvent.press( + getByTestId('campaign-tile-remind-me-camp-remind-notifications'), + ); + }); + + expect(mockEnableNotificationsNudge).toHaveBeenCalledWith( + expect.objectContaining({ + label: 'Turn on', + onPress: expect.any(Function), + }), + ); + expect(mockSetItem).not.toHaveBeenCalled(); + expect(mockTrackEvent).not.toHaveBeenCalled(); + + const linkButtonOptions = mockEnableNotificationsNudge.mock + .calls[0][0] as { + onPress: () => Promise; + }; + await act(async () => { + await linkButtonOptions.onPress(); + }); + expect(mockEnableNotifications).toHaveBeenCalledTimes(1); + + notificationsEnabled = true; + rerender(); + + await waitFor(() => { + expect(mockSetItem).toHaveBeenCalledWith( + reminderStorageKeyForComposite( + `${TEST_REWARDS_SUBSCRIPTION_ID}:camp-remind-notifications`, + ), + '1', + ); + }); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.REWARDS_CAMPAIGN_REMINDER_SUBSCRIBED, + ); + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + }); + + it('cancels pending reminder subscription when the notifications nudge is dismissed', async () => { + let notificationsEnabled = false; + mockUseSelector.mockImplementation((selector) => { + if (selector === selectRewardsSubscriptionId) { + return TEST_REWARDS_SUBSCRIPTION_ID; + } + if ( + selector === selectIsMetamaskNotificationsEnabled || + selector === selectIsMetaMaskPushNotificationsEnabled + ) { + return notificationsEnabled; + } + return undefined; + }); + (getCampaignStatusInfo as jest.Mock).mockReturnValue({ + status: 'upcoming', + statusLabel: 'Coming soon', + dateLabel: 'Starts June 1', + dateLabelIcon: 'Speed', + }); + const campaign = createTestCampaign({ + id: 'camp-dismiss-nudge', + type: CampaignType.PERPS_TRADING, + startDate: '2028-07-15T00:00:00.000Z', + }); + + const { getByTestId, rerender } = render( + , + ); + await waitFor(() => { + expect( + getByTestId('campaign-tile-remind-me-camp-dismiss-nudge'), + ).toBeTruthy(); + }); + + await act(async () => { + fireEvent.press( + getByTestId('campaign-tile-remind-me-camp-dismiss-nudge'), + ); + }); + + const toastConfig = mockShowToast.mock.calls[0][0] as { + closeButtonOptions: { onPress: () => void }; + }; + act(() => { + toastConfig.closeButtonOptions.onPress(); + }); + + notificationsEnabled = true; + rerender(); + + expect(mockSetItem).not.toHaveBeenCalled(); + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + + it('does not show Notify me when the notifications feature flag is off', async () => { + (isNotificationsFeatureEnabled as jest.Mock).mockReturnValue(false); + mockUseSelector.mockImplementation((selector) => { + if (selector === selectRewardsSubscriptionId) { + return TEST_REWARDS_SUBSCRIPTION_ID; + } + if ( + selector === selectIsMetamaskNotificationsEnabled || + selector === selectIsMetaMaskPushNotificationsEnabled + ) { + return true; + } + return undefined; + }); + (getCampaignStatusInfo as jest.Mock).mockReturnValue({ + status: 'upcoming', + statusLabel: 'Coming soon', + dateLabel: 'Starts June 1', + dateLabelIcon: 'Speed', + }); + const campaign = createTestCampaign({ + id: 'camp-cannot-prompt', + type: CampaignType.PERPS_TRADING, + }); + + const { queryByTestId } = render(); + + await waitFor(() => { + expect( + queryByTestId('campaign-tile-remind-me-camp-cannot-prompt'), + ).toBeNull(); + }); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + it('does not show Notify me when storage already has subscription:campaign composite', async () => { mockGetItemSync.mockImplementation((key: string) => key === diff --git a/app/components/UI/Rewards/hooks/useCampaignReminderActions.test.ts b/app/components/UI/Rewards/hooks/useCampaignReminderActions.test.ts new file mode 100644 index 00000000000..6003b493f85 --- /dev/null +++ b/app/components/UI/Rewards/hooks/useCampaignReminderActions.test.ts @@ -0,0 +1,378 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { waitFor } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; +import { + buildCampaignReminderCompositeKey, + reminderStorageKeyForComposite, + useCampaignReminderActions, +} from './useCampaignReminderActions'; +import { + CampaignType, + type CampaignDto, +} from '../../../../core/Engine/controllers/rewards-controller/types'; +import { MetaMetricsEvents } from '../../../../core/Analytics'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; +import { + selectIsMetamaskNotificationsEnabled, + selectIsMetaMaskPushNotificationsEnabled, +} from '../../../../selectors/notifications'; +import { isNotificationsFeatureEnabled } from '../../../../util/notifications/constants'; + +const mockTrackEvent = jest.fn(); +const mockCreateEventBuilder = jest.fn(); +const mockShowToast = jest.fn(); +const mockEnableNotifications = jest.fn(); +const mockEnableNotificationsNudge = jest.fn( + (linkButtonOptions: { label: string; onPress: () => Promise }) => ({ + variant: 'Plain', + hasNoTimeout: true, + linkButtonOptions, + closeButtonOptions: { + onPress: jest.fn(), + }, + }), +); +const mockGetItemSync = jest.fn((_key: string): string | null => null); +const mockSetItem = jest.fn( + (_key: string, _value: string): Promise => Promise.resolve(), +); +let mockEnableNotificationsLoading = false; + +const TEST_REWARDS_SUBSCRIPTION_ID = 'test-rewards-sub-id'; +const TEST_CAMPAIGN_ID = 'test-campaign-id'; +const TEST_CAMPAIGN_START_DATE = '2028-07-15T00:00:00.000Z'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('../../../../store/storage-wrapper', () => ({ + __esModule: true, + default: { + getItemSync: (key: string) => mockGetItemSync(key), + setItem: (key: string, value: string) => mockSetItem(key, value), + }, +})); + +jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: jest.fn(() => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + })), +})); + +jest.mock('./useRewardsToast', () => ({ + __esModule: true, + default: jest.fn(() => ({ + showToast: mockShowToast, + RewardsToastOptions: { + success: jest.fn((title: string, subtitle?: string) => ({ + variant: 'success', + title, + subtitle, + })), + error: jest.fn((title: string, subtitle?: string) => ({ + variant: 'error', + title, + subtitle, + })), + enableNotificationsNudge: mockEnableNotificationsNudge, + loading: jest.fn((title: string, subtitle?: string) => ({ + variant: 'loading', + title, + subtitle, + })), + }, + })), +})); + +jest.mock('../../../../util/notifications/hooks/useNotifications', () => ({ + useEnableNotifications: jest.fn(() => ({ + enableNotifications: mockEnableNotifications, + loading: mockEnableNotificationsLoading, + })), +})); + +jest.mock('../../../../util/notifications/constants', () => ({ + isNotificationsFeatureEnabled: jest.fn(() => true), +})); + +jest.mock('../../../../../locales/i18n', () => ({ + strings: (key: string) => { + const translations: Record = { + 'rewards.campaign.remind_me_success_toast': 'We will notify you.', + 'rewards.campaign.remind_me_save_error': 'Save failed.', + 'rewards.notifications_nudge.turn_on_button': 'Turn on', + }; + return translations[key] || key; + }, +})); + +const mockUseSelector = useSelector as jest.MockedFunction; + +const createCampaign = (overrides = {}): CampaignDto => ({ + id: TEST_CAMPAIGN_ID, + type: CampaignType.PERPS_TRADING, + name: 'Test Campaign', + startDate: TEST_CAMPAIGN_START_DATE, + endDate: '2028-12-31T23:59:59.999Z', + termsAndConditions: null, + excludedRegions: [], + details: null, + featured: true, + showUpcomingDate: false, + ...overrides, +}); + +function mockSelectors({ + subscriptionId = TEST_REWARDS_SUBSCRIPTION_ID, + notificationsEnabled = true, +}: { + subscriptionId?: string | null; + notificationsEnabled?: boolean; +} = {}) { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectRewardsSubscriptionId) { + return subscriptionId; + } + if ( + selector === selectIsMetamaskNotificationsEnabled || + selector === selectIsMetaMaskPushNotificationsEnabled + ) { + return notificationsEnabled; + } + return undefined; + }); +} + +describe('useCampaignReminderActions', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockEnableNotifications.mockResolvedValue(undefined); + mockEnableNotificationsLoading = false; + mockGetItemSync.mockReturnValue(null); + mockSetItem.mockResolvedValue(undefined); + mockSelectors(); + (isNotificationsFeatureEnabled as jest.Mock).mockReturnValue(true); + mockCreateEventBuilder.mockImplementation(() => { + const builder = { + addProperties: jest.fn(), + build: jest.fn(() => ({ category: 'test-event' })), + }; + builder.addProperties.mockReturnValue(builder); + return builder; + }); + }); + + it('shows Remind Me CTA after storage hydration when reminder is enabled', async () => { + const { result } = renderHook(() => + useCampaignReminderActions(createCampaign(), true), + ); + + await waitFor(() => { + expect(result.current.showRemindMeCta).toBe(true); + }); + }); + + it('does not show Remind Me CTA when the reminder feature is disabled', async () => { + const { result } = renderHook(() => + useCampaignReminderActions(createCampaign(), false), + ); + + await waitFor(() => { + expect(result.current.showRemindMeCta).toBe(false); + }); + }); + + it('does not show Remind Me CTA when storage already has the reminder', async () => { + mockGetItemSync.mockReturnValueOnce('1'); + + const { result } = renderHook(() => + useCampaignReminderActions(createCampaign(), true), + ); + + await waitFor(() => { + expect(result.current.showRemindMeCta).toBe(false); + }); + }); + + it('does not show Remind Me CTA when subscription id is missing', async () => { + mockSelectors({ subscriptionId: null }); + + const { result } = renderHook(() => + useCampaignReminderActions(createCampaign(), true), + ); + + await waitFor(() => { + expect(result.current.showRemindMeCta).toBe(false); + }); + }); + + it('shows Remind Me CTA when notifications are disabled even if reminder is already stored', async () => { + mockSelectors({ notificationsEnabled: false }); + mockGetItemSync.mockReturnValue('1'); + + const { result } = renderHook(() => + useCampaignReminderActions(createCampaign(), true), + ); + + await waitFor(() => { + expect(result.current.showRemindMeCta).toBe(true); + }); + }); + + it('does not show Remind Me CTA when notifications are enabled and reminder is already stored', async () => { + mockSelectors({ notificationsEnabled: true }); + mockGetItemSync.mockReturnValue('1'); + + const { result } = renderHook(() => + useCampaignReminderActions(createCampaign(), true), + ); + + await waitFor(() => { + expect(result.current.showRemindMeCta).toBe(false); + }); + }); + + it('does not show Remind Me CTA when the notifications feature flag is off', async () => { + (isNotificationsFeatureEnabled as jest.Mock).mockReturnValue(false); + + const { result } = renderHook(() => + useCampaignReminderActions(createCampaign(), true), + ); + + await waitFor(() => { + expect(result.current.showRemindMeCta).toBe(false); + }); + }); + + it('persists, tracks, and shows success toast when notifications are already enabled', async () => { + const campaign = createCampaign(); + const { result } = renderHook(() => + useCampaignReminderActions(campaign, true), + ); + + await waitFor(() => { + expect(result.current.showRemindMeCta).toBe(true); + }); + + await act(async () => { + await result.current.handleRemindMePress(); + }); + + expect(mockSetItem).toHaveBeenCalledWith( + reminderStorageKeyForComposite( + `${TEST_REWARDS_SUBSCRIPTION_ID}:${TEST_CAMPAIGN_ID}`, + ), + '1', + ); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.REWARDS_CAMPAIGN_REMINDER_SUBSCRIBED, + ); + const builder = mockCreateEventBuilder.mock.results[0]?.value as { + addProperties: jest.Mock; + }; + expect(builder.addProperties).toHaveBeenCalledWith({ + campaign_id: TEST_CAMPAIGN_ID, + campaign_starts_at: TEST_CAMPAIGN_START_DATE, + }); + expect(mockTrackEvent).toHaveBeenCalledWith({ category: 'test-event' }); + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: 'success', + title: 'We will notify you.', + }), + ); + expect(result.current.showRemindMeCta).toBe(false); + }); + + it('shows error toast and does not track when reminder storage fails', async () => { + mockSetItem.mockRejectedValueOnce(new Error('disk full')); + const { result } = renderHook(() => + useCampaignReminderActions(createCampaign(), true), + ); + + await waitFor(() => { + expect(result.current.showRemindMeCta).toBe(true); + }); + + await act(async () => { + await result.current.handleRemindMePress(); + }); + + expect(mockTrackEvent).not.toHaveBeenCalled(); + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: 'error', + title: 'Save failed.', + }), + ); + expect(result.current.showRemindMeCta).toBe(true); + }); + + it('prompts for notifications and defers subscription until notifications are enabled', async () => { + let notificationsEnabled = false; + mockSelectors({ notificationsEnabled }); + const campaign = createCampaign(); + const { result, rerender } = renderHook(() => + useCampaignReminderActions(campaign, true), + ); + + await waitFor(() => { + expect(result.current.showRemindMeCta).toBe(true); + }); + + await act(async () => { + await result.current.handleRemindMePress(); + }); + + expect(mockEnableNotificationsNudge).toHaveBeenCalledWith( + expect.objectContaining({ + label: 'Turn on', + onPress: expect.any(Function), + }), + ); + expect(mockSetItem).not.toHaveBeenCalled(); + expect(mockTrackEvent).not.toHaveBeenCalled(); + + const linkButtonOptions = mockEnableNotificationsNudge.mock.calls[0][0] as { + onPress: () => Promise; + }; + await act(async () => { + await linkButtonOptions.onPress(); + }); + expect(mockEnableNotifications).toHaveBeenCalledTimes(1); + + notificationsEnabled = true; + mockSelectors({ notificationsEnabled }); + rerender(); + + await waitFor(() => { + expect(mockSetItem).toHaveBeenCalledWith( + reminderStorageKeyForComposite( + `${TEST_REWARDS_SUBSCRIPTION_ID}:${TEST_CAMPAIGN_ID}`, + ), + '1', + ); + }); + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + }); +}); + +describe('campaign reminder storage helpers', () => { + describe('buildCampaignReminderCompositeKey', () => { + it('joins subscription and campaign with colon', () => { + expect(buildCampaignReminderCompositeKey('sub-1', 'camp-2')).toBe( + 'sub-1:camp-2', + ); + }); + }); + + describe('reminderStorageKeyForComposite', () => { + it('prefixes composite key for isolated MMKV rows', () => { + expect(reminderStorageKeyForComposite('sub-1:camp-2')).toBe( + 'rewards_campaign_reminder_subscribed::sub-1:camp-2', + ); + }); + }); +}); diff --git a/app/components/UI/Rewards/hooks/useCampaignReminderActions.ts b/app/components/UI/Rewards/hooks/useCampaignReminderActions.ts index 96574c461c1..884964159ac 100644 --- a/app/components/UI/Rewards/hooks/useCampaignReminderActions.ts +++ b/app/components/UI/Rewards/hooks/useCampaignReminderActions.ts @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useSelector } from 'react-redux'; import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../../core/Analytics'; @@ -6,7 +6,24 @@ import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; import type { CampaignDto } from '../../../../core/Engine/controllers/rewards-controller/types'; import { strings } from '../../../../../locales/i18n'; import useRewardsToast from './useRewardsToast'; -import { useCampaignReminderSubscription } from './useCampaignReminderSubscriptions'; +import { useRewardsNotificationsNudge } from './useRewardsNotificationsNudge'; +import StorageWrapper from '../../../../store/storage-wrapper'; + +const REMINDER_SUBSCRIBED_VALUE = '1'; + +export function buildCampaignReminderCompositeKey( + subscriptionId: string, + campaignId: string, +): string { + return `${subscriptionId}:${campaignId}`; +} + +/** + * One MMKV key per subscription:campaign reminder (no shared JSON list). + */ +export function reminderStorageKeyForComposite(compositeKey: string): string { + return `rewards_campaign_reminder_subscribed::${compositeKey}`; +} /** * Shared storage, analytics, and toasts for campaign start reminders @@ -20,16 +37,63 @@ export function useCampaignReminderActions( handleRemindMePress: () => Promise; } { const subscriptionId = useSelector(selectRewardsSubscriptionId); + const isStoredRef = useRef(false); + const [hydrated, setHydrated] = useState(false); + const [renderKey, setRenderKey] = useState(0); const { trackEvent, createEventBuilder } = useAnalytics(); const { showToast, RewardsToastOptions } = useRewardsToast(); - const { showRemindMeCta, persistReminderSubscription } = - useCampaignReminderSubscription({ - subscriptionId, - campaignId: campaign.id, - enabled, - }); + const { + canPromptToEnableNotifications, + areNotificationsEnabled, + runAfterNotificationsEnabled, + } = useRewardsNotificationsNudge({ enabled }); - const handleRemindMePress = useCallback(async () => { + const compositeKey = useMemo(() => { + if (!subscriptionId || !campaign.id) { + return null; + } + return buildCampaignReminderCompositeKey(subscriptionId, campaign.id); + }, [subscriptionId, campaign.id]); + + useEffect(() => { + if (!enabled) { + setHydrated(true); + return; + } + if (!compositeKey) { + setHydrated(true); + return; + } + const storageKey = reminderStorageKeyForComposite(compositeKey); + const raw = StorageWrapper.getItemSync(storageKey); + isStoredRef.current = raw === REMINDER_SUBSCRIBED_VALUE; + setHydrated(true); + setRenderKey((k) => k + 1); + }, [enabled, compositeKey]); + + const showRemindMeCta = Boolean( + renderKey >= 0 && + enabled && + canPromptToEnableNotifications && + hydrated && + compositeKey && + (!areNotificationsEnabled || !isStoredRef.current), + ); + + const persistReminderSubscription = useCallback(async () => { + if (!compositeKey) { + throw new Error('Missing subscription or campaign for reminder storage'); + } + if (isStoredRef.current) { + return; + } + const storageKey = reminderStorageKeyForComposite(compositeKey); + await StorageWrapper.setItem(storageKey, REMINDER_SUBSCRIBED_VALUE); + isStoredRef.current = true; + setRenderKey((k) => k + 1); + }, [compositeKey]); + + const subscribeToReminder = useCallback(async () => { try { await persistReminderSubscription(); } catch { @@ -63,5 +127,9 @@ export function useCampaignReminderActions( RewardsToastOptions, ]); + const handleRemindMePress = useCallback(async () => { + await runAfterNotificationsEnabled(subscribeToReminder); + }, [runAfterNotificationsEnabled, subscribeToReminder]); + return { showRemindMeCta, handleRemindMePress }; } diff --git a/app/components/UI/Rewards/hooks/useCampaignReminderSubscriptions.test.ts b/app/components/UI/Rewards/hooks/useCampaignReminderSubscriptions.test.ts deleted file mode 100644 index 22af5b14995..00000000000 --- a/app/components/UI/Rewards/hooks/useCampaignReminderSubscriptions.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { - buildCampaignReminderCompositeKey, - reminderStorageKeyForComposite, -} from './useCampaignReminderSubscriptions'; - -describe('useCampaignReminderSubscriptions helpers', () => { - describe('buildCampaignReminderCompositeKey', () => { - it('joins subscription and campaign with colon', () => { - expect(buildCampaignReminderCompositeKey('sub-1', 'camp-2')).toBe( - 'sub-1:camp-2', - ); - }); - }); - - describe('reminderStorageKeyForComposite', () => { - it('prefixes composite key for isolated MMKV rows', () => { - expect(reminderStorageKeyForComposite('sub-1:camp-2')).toBe( - 'rewards_campaign_reminder_subscribed::sub-1:camp-2', - ); - }); - }); -}); diff --git a/app/components/UI/Rewards/hooks/useCampaignReminderSubscriptions.ts b/app/components/UI/Rewards/hooks/useCampaignReminderSubscriptions.ts deleted file mode 100644 index ee4b5ad512f..00000000000 --- a/app/components/UI/Rewards/hooks/useCampaignReminderSubscriptions.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import StorageWrapper from '../../../../store/storage-wrapper'; - -const REMINDER_SUBSCRIBED_VALUE = '1'; - -export function buildCampaignReminderCompositeKey( - subscriptionId: string, - campaignId: string, -): string { - return `${subscriptionId}:${campaignId}`; -} - -/** - * One MMKV key per subscription:campaign reminder (no shared JSON list). - */ -export function reminderStorageKeyForComposite(compositeKey: string): string { - return `rewards_campaign_reminder_subscribed::${compositeKey}`; -} - -/** - * Local "Remind me" state scoped by Rewards subscription + campaign. - * CTA is shown only after hydration when this composite row is not stored. - */ -export function useCampaignReminderSubscription(options: { - subscriptionId: string | null | undefined; - campaignId: string; - enabled: boolean; -}): { - showRemindMeCta: boolean; - persistReminderSubscription: () => Promise; -} { - const { subscriptionId, campaignId, enabled } = options; - const isStoredRef = useRef(false); - const [hydrated, setHydrated] = useState(false); - const [renderKey, setRenderKey] = useState(0); - - const compositeKey = useMemo(() => { - if (!subscriptionId || !campaignId) { - return null; - } - return buildCampaignReminderCompositeKey(subscriptionId, campaignId); - }, [subscriptionId, campaignId]); - - useEffect(() => { - if (!enabled) { - setHydrated(true); - return; - } - if (!compositeKey) { - setHydrated(true); - return; - } - const storageKey = reminderStorageKeyForComposite(compositeKey); - const raw = StorageWrapper.getItemSync(storageKey); - isStoredRef.current = raw === REMINDER_SUBSCRIBED_VALUE; - setHydrated(true); - setRenderKey((k) => k + 1); - }, [enabled, compositeKey]); - - const showRemindMeCta = Boolean( - renderKey >= 0 && - enabled && - hydrated && - compositeKey && - !isStoredRef.current, - ); - - const persistReminderSubscription = useCallback(async () => { - if (!compositeKey) { - throw new Error('Missing subscription or campaign for reminder storage'); - } - if (isStoredRef.current) { - return; - } - const storageKey = reminderStorageKeyForComposite(compositeKey); - await StorageWrapper.setItem(storageKey, REMINDER_SUBSCRIBED_VALUE); - isStoredRef.current = true; - setRenderKey((k) => k + 1); - }, [compositeKey]); - - return { showRemindMeCta, persistReminderSubscription }; -} diff --git a/app/components/UI/Rewards/hooks/useLinkAccountAddress.test.ts b/app/components/UI/Rewards/hooks/useLinkAccountAddress.test.ts index 5efbf8058e7..f41f2351164 100644 --- a/app/components/UI/Rewards/hooks/useLinkAccountAddress.test.ts +++ b/app/components/UI/Rewards/hooks/useLinkAccountAddress.test.ts @@ -82,6 +82,13 @@ describe('useLinkAccountAddress', () => { iconName: 'confirmation', hapticsType: 'success', }), + enableNotificationsNudge: jest.fn().mockReturnValue({ + variant: 'plain', + hapticsType: 'warning', + }), + loading: jest.fn().mockReturnValue({ + variant: 'loading', + }), }; const mockAccount: InternalAccount = { diff --git a/app/components/UI/Rewards/hooks/useLinkAccountGroup.test.ts b/app/components/UI/Rewards/hooks/useLinkAccountGroup.test.ts index e59a982d667..5e99c8b0f20 100644 --- a/app/components/UI/Rewards/hooks/useLinkAccountGroup.test.ts +++ b/app/components/UI/Rewards/hooks/useLinkAccountGroup.test.ts @@ -103,6 +103,13 @@ describe('useLinkAccountGroup', () => { iconName: 'error', hapticsType: 'error', }), + enableNotificationsNudge: jest.fn().mockReturnValue({ + variant: 'plain', + hapticsType: 'warning', + }), + loading: jest.fn().mockReturnValue({ + variant: 'loading', + }), }; // Mock account data diff --git a/app/components/UI/Rewards/hooks/useRewardsNotificationsNudge.test.tsx b/app/components/UI/Rewards/hooks/useRewardsNotificationsNudge.test.tsx new file mode 100644 index 00000000000..d406b8ff7a6 --- /dev/null +++ b/app/components/UI/Rewards/hooks/useRewardsNotificationsNudge.test.tsx @@ -0,0 +1,656 @@ +import React from 'react'; +import { AppState } from 'react-native'; +import { renderHook, act } from '@testing-library/react-hooks'; +import { waitFor } from '@testing-library/react-native'; + +jest.mock('react-native', () => ({ + ...jest.requireActual('react-native'), + AppState: { addEventListener: jest.fn() }, +})); +const mockAppStateAddEventListener = AppState.addEventListener as jest.Mock; +import { useSelector } from 'react-redux'; +import { ToastContext } from '../../../../component-library/components/Toast'; +import { useRewardsNotificationsNudge } from './useRewardsNotificationsNudge'; +import { + selectIsMetamaskNotificationsEnabled, + selectIsMetaMaskPushNotificationsEnabled, +} from '../../../../selectors/notifications'; +import { isNotificationsFeatureEnabled } from '../../../../util/notifications/constants'; + +const mockShowToast = jest.fn(); +const mockEnableNotifications = jest.fn(); +const mockOriginalCloseButtonPress = jest.fn(); +const mockEnableNotificationsNudge = jest.fn( + (linkButtonOptions: { label: string; onPress: () => Promise }) => ({ + variant: 'Plain', + hasNoTimeout: true, + linkButtonOptions, + closeButtonOptions: { + onPress: mockOriginalCloseButtonPress, + }, + }), +); +const mockLoadingToast = jest.fn((title: string, subtitle?: string) => ({ + variant: 'loading', + title, + subtitle, +})); +const mockErrorToast = jest.fn((title: string) => ({ + variant: 'error', + title, +})); +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('./useRewardsToast', () => ({ + __esModule: true, + default: jest.fn(() => ({ + showToast: mockShowToast, + RewardsToastOptions: { + enableNotificationsNudge: mockEnableNotificationsNudge, + loading: mockLoadingToast, + error: mockErrorToast, + }, + })), +})); + +jest.mock('../../../../util/notifications/hooks/useNotifications', () => ({ + useEnableNotifications: jest.fn(() => ({ + enableNotifications: mockEnableNotifications, + })), +})); + +jest.mock('../../../../util/notifications/constants', () => ({ + isNotificationsFeatureEnabled: jest.fn(() => true), +})); + +jest.mock( + '../../../../util/notifications/services/NotificationService', + () => ({ + __esModule: true, + default: { openSystemSettings: jest.fn() }, + getPushPermission: jest.fn().mockResolvedValue('authorized'), + }), +); +const mockNotificationService = jest.requireMock( + '../../../../util/notifications/services/NotificationService', +); +const mockOpenSystemSettings = mockNotificationService.default + .openSystemSettings as jest.Mock; +const mockGetPushPermission = + mockNotificationService.getPushPermission as jest.Mock; + +jest.mock('../../../../../locales/i18n', () => ({ + strings: (key: string) => { + const translations: Record = { + 'rewards.notifications_nudge.turn_on_button': 'Turn on', + 'rewards.notifications_nudge.loading': 'Enabling notifications...', + 'rewards.notifications_nudge.loading_description': + 'This may take a moment.', + 'rewards.notifications_nudge.enable_error': + 'Failed to enable notifications', + }; + return translations[key] || key; + }, +})); + +const mockUseSelector = useSelector as jest.MockedFunction; +const mockCloseToast = jest.fn(); + +function mockSelectors({ + notificationsEnabled, +}: { + notificationsEnabled: boolean; +}) { + mockUseSelector.mockImplementation((selector) => { + if ( + selector === selectIsMetamaskNotificationsEnabled || + selector === selectIsMetaMaskPushNotificationsEnabled + ) { + return notificationsEnabled; + } + return undefined; + }); +} + +function renderNudgeHook(options?: { + enabled?: boolean; + onNotificationsEnabled?: () => void; +}) { + const toastRef = { + current: { + showToast: jest.fn(), + closeToast: mockCloseToast, + }, + }; + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement( + ToastContext.Provider, + { value: { toastRef } }, + children, + ); + + return renderHook(() => useRewardsNotificationsNudge(options), { wrapper }); +} + +describe('useRewardsNotificationsNudge', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockEnableNotifications.mockResolvedValue(undefined); + mockGetPushPermission.mockResolvedValue('authorized'); + mockSelectors({ notificationsEnabled: true }); + (isNotificationsFeatureEnabled as jest.Mock).mockReturnValue(true); + mockAppStateAddEventListener.mockReturnValue({ remove: jest.fn() }); + }); + + it('returns enabled state when notifications and push are enabled', () => { + const { result } = renderNudgeHook(); + + expect(result.current.areNotificationsEnabled).toBe(true); + expect(result.current.canPromptToEnableNotifications).toBe(true); + expect(result.current.shouldPromptToEnableNotifications).toBe(false); + }); + + it('returns prompt state when notifications are disabled and feature flag is on', () => { + mockSelectors({ notificationsEnabled: false }); + + const { result } = renderNudgeHook(); + + expect(result.current.areNotificationsEnabled).toBe(false); + expect(result.current.canPromptToEnableNotifications).toBe(true); + expect(result.current.shouldPromptToEnableNotifications).toBe(true); + }); + + it('shows the notifications nudge and enables notifications from its CTA', async () => { + mockSelectors({ notificationsEnabled: false }); + const { result } = renderNudgeHook(); + + let didShow = false; + act(() => { + didShow = result.current.showEnableNotificationsNudge(); + }); + + expect(didShow).toBe(true); + expect(mockEnableNotificationsNudge).toHaveBeenCalledWith({ + label: 'Turn on', + onPress: expect.any(Function), + }); + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: 'Plain', + closeButtonOptions: expect.objectContaining({ + onPress: expect.any(Function), + }), + }), + ); + + const linkButtonOptions = mockEnableNotificationsNudge.mock.calls[0][0] as { + onPress: () => Promise; + }; + await act(async () => { + await linkButtonOptions.onPress(); + }); + + expect(mockEnableNotifications).toHaveBeenCalledTimes(1); + expect(mockCloseToast).toHaveBeenCalledTimes(1); + expect(mockShowToast).toHaveBeenCalledTimes(2); + expect(mockShowToast).toHaveBeenLastCalledWith( + expect.objectContaining({ variant: 'loading' }), + ); + }); + + it('shows loading toast when Turn On CTA is pressed', async () => { + mockSelectors({ notificationsEnabled: false }); + const { result } = renderNudgeHook(); + + act(() => { + result.current.showEnableNotificationsNudge(); + }); + + const linkButtonOptions = mockEnableNotificationsNudge.mock.calls[0][0] as { + onPress: () => Promise; + }; + await act(async () => { + await linkButtonOptions.onPress(); + }); + + expect(mockShowToast).toHaveBeenCalledTimes(2); + expect(mockShowToast).toHaveBeenLastCalledWith( + expect.objectContaining({ variant: 'loading' }), + ); + }); + + it('shows error toast if enableNotifications fails', async () => { + mockSelectors({ notificationsEnabled: false }); + mockEnableNotifications.mockRejectedValue(new Error('failed')); + const { result } = renderNudgeHook(); + + act(() => { + result.current.showEnableNotificationsNudge(); + }); + + const linkButtonOptions = mockEnableNotificationsNudge.mock.calls[0][0] as { + onPress: () => Promise; + }; + await act(async () => { + await linkButtonOptions.onPress(); + }); + + expect(mockCloseToast).toHaveBeenCalledTimes(1); + expect(mockShowToast).toHaveBeenLastCalledWith( + expect.objectContaining({ variant: 'error' }), + ); + }); + + it('calls onNotificationsEnabled callback after successful enable', async () => { + mockSelectors({ notificationsEnabled: false }); + const onNotificationsEnabled = jest.fn(); + const { result } = renderNudgeHook({ onNotificationsEnabled }); + + act(() => { + result.current.showEnableNotificationsNudge(); + }); + + const linkButtonOptions = mockEnableNotificationsNudge.mock.calls[0][0] as { + onPress: () => Promise; + }; + await act(async () => { + await linkButtonOptions.onPress(); + }); + + expect(mockEnableNotifications).toHaveBeenCalledTimes(1); + expect(onNotificationsEnabled).toHaveBeenCalledTimes(1); + }); + + it('does not call onNotificationsEnabled callback when enableNotifications fails', async () => { + mockSelectors({ notificationsEnabled: false }); + mockEnableNotifications.mockRejectedValue(new Error('failed')); + const onNotificationsEnabled = jest.fn(); + const { result } = renderNudgeHook({ onNotificationsEnabled }); + + act(() => { + result.current.showEnableNotificationsNudge(); + }); + + const linkButtonOptions = mockEnableNotificationsNudge.mock.calls[0][0] as { + onPress: () => Promise; + }; + await act(async () => { + await linkButtonOptions.onPress(); + }); + + expect(onNotificationsEnabled).not.toHaveBeenCalled(); + }); + + it('does not call onNotificationsEnabled when push permission is denied', async () => { + mockSelectors({ notificationsEnabled: false }); + mockGetPushPermission.mockResolvedValue('denied'); + const onNotificationsEnabled = jest.fn(); + const { result } = renderNudgeHook({ onNotificationsEnabled }); + + act(() => { + result.current.showEnableNotificationsNudge(); + }); + + const linkButtonOptions = mockEnableNotificationsNudge.mock.calls[0][0] as { + onPress: () => Promise; + }; + await act(async () => { + await linkButtonOptions.onPress(); + }); + + expect(onNotificationsEnabled).not.toHaveBeenCalled(); + }); + + it('runs deferred action immediately when notifications are already enabled', async () => { + const action = jest.fn(); + const { result } = renderNudgeHook(); + + let didRun = false; + await act(async () => { + didRun = await result.current.runAfterNotificationsEnabled(action); + }); + + expect(didRun).toBe(true); + expect(action).toHaveBeenCalledTimes(1); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + + it('defers action until notifications become enabled', async () => { + let notificationsEnabled = false; + mockSelectors({ notificationsEnabled }); + const action = jest.fn(); + const { result, rerender } = renderNudgeHook(); + + let didRun = true; + await act(async () => { + didRun = await result.current.runAfterNotificationsEnabled(action); + }); + + expect(didRun).toBe(false); + expect(action).not.toHaveBeenCalled(); + expect(mockShowToast).toHaveBeenCalledTimes(1); + + notificationsEnabled = true; + mockSelectors({ notificationsEnabled }); + rerender(); + + await waitFor(() => { + expect(action).toHaveBeenCalledTimes(1); + }); + expect(mockCloseToast).toHaveBeenCalledTimes(1); + }); + + it('shows loading toast before deferred action runs via effect', async () => { + let notificationsEnabled = false; + mockSelectors({ notificationsEnabled }); + const action = jest.fn(); + const { result, rerender } = renderNudgeHook(); + + await act(async () => { + await result.current.runAfterNotificationsEnabled(action); + }); + + notificationsEnabled = true; + mockSelectors({ notificationsEnabled }); + rerender(); + + await waitFor(() => { + expect(action).toHaveBeenCalledTimes(1); + }); + + // nudge (1st call) + loading in effect (2nd call) + expect(mockShowToast).toHaveBeenCalledTimes(2); + expect(mockShowToast).toHaveBeenLastCalledWith( + expect.objectContaining({ variant: 'loading' }), + ); + }); + + it('does not run or prompt when notifications are disabled and feature flag is off', async () => { + mockSelectors({ notificationsEnabled: false }); + (isNotificationsFeatureEnabled as jest.Mock).mockReturnValue(false); + const action = jest.fn(); + const { result } = renderNudgeHook(); + + let didRun = true; + await act(async () => { + didRun = await result.current.runAfterNotificationsEnabled(action); + }); + + expect(didRun).toBe(false); + expect(action).not.toHaveBeenCalled(); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + + it('returns false from showEnableNotificationsNudge when the nudge is unavailable', () => { + mockSelectors({ notificationsEnabled: false }); + (isNotificationsFeatureEnabled as jest.Mock).mockReturnValue(false); + const { result } = renderNudgeHook(); + + let didShow = true; + act(() => { + didShow = result.current.showEnableNotificationsNudge(); + }); + + expect(didShow).toBe(false); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + + it('clears pending deferred action when the nudge is dismissed', async () => { + let notificationsEnabled = false; + mockSelectors({ notificationsEnabled }); + const action = jest.fn(); + const { result, rerender } = renderNudgeHook(); + + await act(async () => { + await result.current.runAfterNotificationsEnabled(action); + }); + + const toastConfig = mockShowToast.mock.calls[0][0] as { + closeButtonOptions: { onPress: () => void }; + }; + act(() => { + toastConfig.closeButtonOptions.onPress(); + }); + + notificationsEnabled = true; + mockSelectors({ notificationsEnabled }); + rerender(); + + expect(mockOriginalCloseButtonPress).toHaveBeenCalledTimes(1); + expect(action).not.toHaveBeenCalled(); + }); + + it('closes toast and opens Settings when push permission is denied before Turn On', async () => { + mockSelectors({ notificationsEnabled: false }); + mockGetPushPermission.mockResolvedValue('denied'); + const { result } = renderNudgeHook(); + + act(() => { + result.current.showEnableNotificationsNudge(); + }); + + const linkButtonOptions = mockEnableNotificationsNudge.mock.calls[0][0] as { + onPress: () => Promise; + }; + await act(async () => { + await linkButtonOptions.onPress(); + }); + + expect(mockGetPushPermission).toHaveBeenCalledTimes(1); + expect(mockEnableNotifications).not.toHaveBeenCalled(); + expect(mockCloseToast).toHaveBeenCalledTimes(1); + expect(mockOpenSystemSettings).toHaveBeenCalledTimes(1); + expect(mockAppStateAddEventListener).toHaveBeenCalledWith( + 'change', + expect.any(Function), + ); + }); + + it('calls enableNotifications after user returns from OS settings with permission granted', async () => { + mockSelectors({ notificationsEnabled: false }); + mockGetPushPermission + .mockResolvedValueOnce('denied') + .mockResolvedValueOnce('authorized'); + + let capturedHandler: ((state: string) => Promise) | null = null; + const mockRemove = jest.fn(); + mockAppStateAddEventListener.mockImplementation( + (_event: string, handler: (state: string) => Promise) => { + capturedHandler = handler; + return { remove: mockRemove }; + }, + ); + + const { result } = renderNudgeHook(); + + act(() => { + result.current.showEnableNotificationsNudge(); + }); + + const linkButtonOptions = mockEnableNotificationsNudge.mock.calls[0][0] as { + onPress: () => Promise; + }; + await act(async () => { + await linkButtonOptions.onPress(); + }); + + expect(mockOpenSystemSettings).toHaveBeenCalledTimes(1); + expect(capturedHandler).not.toBeNull(); + expect(mockEnableNotifications).not.toHaveBeenCalled(); + + await act(async () => { + await capturedHandler?.('active'); + }); + + expect(mockRemove).toHaveBeenCalledTimes(1); + expect(mockGetPushPermission).toHaveBeenCalledTimes(2); + expect(mockEnableNotifications).toHaveBeenCalledTimes(1); + expect(mockShowToast).toHaveBeenLastCalledWith( + expect.objectContaining({ variant: 'loading' }), + ); + }); + + it('does not call enableNotifications if permission is still denied after returning from OS settings', async () => { + mockSelectors({ notificationsEnabled: false }); + mockGetPushPermission.mockResolvedValue('denied'); + + let capturedHandler: ((state: string) => Promise) | null = null; + mockAppStateAddEventListener.mockImplementation( + (_event: string, handler: (state: string) => Promise) => { + capturedHandler = handler; + return { remove: jest.fn() }; + }, + ); + + const { result } = renderNudgeHook(); + + act(() => { + result.current.showEnableNotificationsNudge(); + }); + + const linkButtonOptions = mockEnableNotificationsNudge.mock.calls[0][0] as { + onPress: () => Promise; + }; + await act(async () => { + await linkButtonOptions.onPress(); + }); + + await act(async () => { + await capturedHandler?.('active'); + }); + + expect(mockEnableNotifications).not.toHaveBeenCalled(); + }); + + it('calls onNotificationsEnabled callback after returning from OS settings with permission granted', async () => { + mockSelectors({ notificationsEnabled: false }); + mockGetPushPermission + .mockResolvedValueOnce('denied') + .mockResolvedValueOnce('authorized'); + + let capturedHandler: ((state: string) => Promise) | null = null; + mockAppStateAddEventListener.mockImplementation( + (_event: string, handler: (state: string) => Promise) => { + capturedHandler = handler; + return { remove: jest.fn() }; + }, + ); + + const onNotificationsEnabled = jest.fn(); + const { result } = renderNudgeHook({ onNotificationsEnabled }); + + act(() => { + result.current.showEnableNotificationsNudge(); + }); + + const linkButtonOptions = mockEnableNotificationsNudge.mock.calls[0][0] as { + onPress: () => Promise; + }; + await act(async () => { + await linkButtonOptions.onPress(); + }); + + await act(async () => { + await capturedHandler?.('active'); + }); + + expect(mockEnableNotifications).toHaveBeenCalledTimes(1); + expect(onNotificationsEnabled).toHaveBeenCalledTimes(1); + }); + + it('ignores non-active AppState transitions after opening OS settings', async () => { + mockSelectors({ notificationsEnabled: false }); + mockGetPushPermission.mockResolvedValue('denied'); + + let capturedHandler: ((state: string) => Promise) | null = null; + mockAppStateAddEventListener.mockImplementation( + (_event: string, handler: (state: string) => Promise) => { + capturedHandler = handler; + return { remove: jest.fn() }; + }, + ); + + const { result } = renderNudgeHook(); + + act(() => { + result.current.showEnableNotificationsNudge(); + }); + + const linkButtonOptions = mockEnableNotificationsNudge.mock.calls[0][0] as { + onPress: () => Promise; + }; + await act(async () => { + await linkButtonOptions.onPress(); + }); + + await act(async () => { + await capturedHandler?.('background'); + }); + + expect(mockGetPushPermission).toHaveBeenCalledTimes(1); + expect(mockEnableNotifications).not.toHaveBeenCalled(); + }); + + it('does not open Settings when push permission is authorized before Turn On', async () => { + mockSelectors({ notificationsEnabled: false }); + mockGetPushPermission.mockResolvedValue('authorized'); + const { result } = renderNudgeHook(); + + act(() => { + result.current.showEnableNotificationsNudge(); + }); + + const linkButtonOptions = mockEnableNotificationsNudge.mock.calls[0][0] as { + onPress: () => Promise; + }; + await act(async () => { + await linkButtonOptions.onPress(); + }); + + expect(mockCloseToast).toHaveBeenCalledTimes(1); + expect(mockOpenSystemSettings).not.toHaveBeenCalled(); + }); + + it('closes toast even when called from runAfterNotificationsEnabled flow', async () => { + mockSelectors({ notificationsEnabled: false }); + const action = jest.fn(); + const { result } = renderNudgeHook(); + + await act(async () => { + await result.current.runAfterNotificationsEnabled(action); + }); + + const linkButtonOptions = mockEnableNotificationsNudge.mock.calls[0][0] as { + onPress: () => Promise; + }; + await act(async () => { + await linkButtonOptions.onPress(); + }); + + expect(mockCloseToast).toHaveBeenCalledTimes(1); + }); + + it('closeEnableNotificationsNudge clears pending action and closes the toast', async () => { + let notificationsEnabled = false; + mockSelectors({ notificationsEnabled }); + const action = jest.fn(); + const { result, rerender } = renderNudgeHook(); + + await act(async () => { + await result.current.runAfterNotificationsEnabled(action); + }); + + act(() => { + result.current.closeEnableNotificationsNudge(); + }); + + notificationsEnabled = true; + mockSelectors({ notificationsEnabled }); + rerender(); + + expect(mockCloseToast).toHaveBeenCalledTimes(1); + expect(action).not.toHaveBeenCalled(); + }); +}); diff --git a/app/components/UI/Rewards/hooks/useRewardsNotificationsNudge.ts b/app/components/UI/Rewards/hooks/useRewardsNotificationsNudge.ts new file mode 100644 index 00000000000..979066f98f0 --- /dev/null +++ b/app/components/UI/Rewards/hooks/useRewardsNotificationsNudge.ts @@ -0,0 +1,259 @@ +import { useCallback, useContext, useEffect, useRef } from 'react'; +import { AppState } from 'react-native'; +import { useSelector } from 'react-redux'; +import { ToastContext } from '../../../../component-library/components/Toast'; +import { strings } from '../../../../../locales/i18n'; +import { + selectIsMetamaskNotificationsEnabled, + selectIsMetaMaskPushNotificationsEnabled, +} from '../../../../selectors/notifications'; +import { isNotificationsFeatureEnabled } from '../../../../util/notifications/constants'; +import { useEnableNotifications } from '../../../../util/notifications/hooks/useNotifications'; +import NotificationService, { + getPushPermission, +} from '../../../../util/notifications/services/NotificationService'; +import useRewardsToast from './useRewardsToast'; + +type NotificationsEnabledAction = () => Promise | void; + +interface UseRewardsNotificationsNudgeOptions { + enabled?: boolean; + onNotificationsEnabled?: () => void; +} + +export interface UseRewardsNotificationsNudgeReturn { + areNotificationsEnabled: boolean; + canPromptToEnableNotifications: boolean; + shouldPromptToEnableNotifications: boolean; + showEnableNotificationsNudge: () => boolean; + closeEnableNotificationsNudge: () => void; + runAfterNotificationsEnabled: ( + action: NotificationsEnabledAction, + ) => Promise; +} + +/** + * Shared Rewards notification nudge flow. + * + * Screens can either show the nudge directly, or defer an action until + * MetaMask notifications and push notifications are both enabled. + */ +export function useRewardsNotificationsNudge( + options: UseRewardsNotificationsNudgeOptions = {}, +): UseRewardsNotificationsNudgeReturn { + const { enabled = true, onNotificationsEnabled } = options; + const onNotificationsEnabledRef = useRef(onNotificationsEnabled); + onNotificationsEnabledRef.current = onNotificationsEnabled; + const isMetamaskNotificationsEnabled = useSelector( + selectIsMetamaskNotificationsEnabled, + ); + const isMetaMaskPushNotificationsEnabled = useSelector( + selectIsMetaMaskPushNotificationsEnabled, + ); + const { toastRef } = useContext(ToastContext); + const { showToast, RewardsToastOptions } = useRewardsToast(); + const { enableNotifications } = useEnableNotifications({ + nudgeEnablePush: true, + }); + const notificationsEnableInFlightRef = useRef(false); + const pendingActionRef = useRef(null); + const appStateSubscriptionRef = useRef | null>(null); + + const canPromptToEnableNotifications = isNotificationsFeatureEnabled(); + const areNotificationsEnabled = + isMetamaskNotificationsEnabled && isMetaMaskPushNotificationsEnabled; + const shouldPromptToEnableNotifications = + enabled && canPromptToEnableNotifications && !areNotificationsEnabled; + + // Kept in sync on every render so closeEnableNotificationsNudge can read the + // latest value without being recreated whenever areNotificationsEnabled changes. + const areNotificationsEnabledRef = useRef(areNotificationsEnabled); + areNotificationsEnabledRef.current = areNotificationsEnabled; + + const closeEnableNotificationsNudge = useCallback(() => { + pendingActionRef.current = null; + // If notifications are now enabled the nudge is already gone (replaced by + // loading → success toast). Calling closeToast here would kill the success + // toast that was just shown, so we skip it. + if (!areNotificationsEnabledRef.current) { + toastRef?.current?.closeToast(); + } + }, [toastRef]); + + const handleTurnOnNotifications = useCallback(async () => { + if (!enabled || notificationsEnableInFlightRef.current) { + return; + } + notificationsEnableInFlightRef.current = true; + showToast( + RewardsToastOptions.loading( + strings('rewards.notifications_nudge.loading'), + strings('rewards.notifications_nudge.loading_description'), + ), + ); + try { + const permission = await getPushPermission(); + if (permission === 'denied') { + toastRef?.current?.closeToast(); + NotificationService.openSystemSettings(); + appStateSubscriptionRef.current = AppState.addEventListener( + 'change', + async (nextState) => { + if (nextState !== 'active') return; + appStateSubscriptionRef.current?.remove(); + appStateSubscriptionRef.current = null; + const retryPermission = await getPushPermission(); + if (retryPermission === 'denied') return; + notificationsEnableInFlightRef.current = true; + showToast( + RewardsToastOptions.loading( + strings('rewards.notifications_nudge.loading'), + strings('rewards.notifications_nudge.loading_description'), + ), + ); + try { + await enableNotifications(); + toastRef?.current?.closeToast(); + onNotificationsEnabledRef.current?.(); + } catch { + toastRef?.current?.closeToast(); + showToast( + RewardsToastOptions.error( + strings('rewards.notifications_nudge.enable_error'), + ), + ); + } finally { + notificationsEnableInFlightRef.current = false; + } + }, + ); + return; + } + await enableNotifications(); + toastRef?.current?.closeToast(); + onNotificationsEnabledRef.current?.(); + } catch { + toastRef?.current?.closeToast(); + showToast( + RewardsToastOptions.error( + strings('rewards.notifications_nudge.enable_error'), + ), + ); + } finally { + notificationsEnableInFlightRef.current = false; + } + }, [enableNotifications, enabled, toastRef, showToast, RewardsToastOptions]); + + const showEnableNotificationsNudge = useCallback(() => { + if (!shouldPromptToEnableNotifications) { + return false; + } + + const nudgeConfig = RewardsToastOptions.enableNotificationsNudge({ + label: strings('rewards.notifications_nudge.turn_on_button'), + onPress: handleTurnOnNotifications, + }); + + showToast( + nudgeConfig.closeButtonOptions + ? { + ...nudgeConfig, + closeButtonOptions: { + ...nudgeConfig.closeButtonOptions, + onPress: () => { + pendingActionRef.current = null; + nudgeConfig.closeButtonOptions?.onPress?.(); + }, + }, + } + : nudgeConfig, + ); + return true; + }, [ + RewardsToastOptions, + handleTurnOnNotifications, + shouldPromptToEnableNotifications, + showToast, + ]); + + const runAfterNotificationsEnabled = useCallback( + async (action: NotificationsEnabledAction) => { + if (!enabled) { + return false; + } + if (areNotificationsEnabled) { + await action(); + return true; + } + if (!canPromptToEnableNotifications) { + return false; + } + + pendingActionRef.current = action; + showEnableNotificationsNudge(); + return false; + }, + [ + areNotificationsEnabled, + canPromptToEnableNotifications, + enabled, + showEnableNotificationsNudge, + ], + ); + + useEffect(() => { + if (!pendingActionRef.current || !areNotificationsEnabled || !enabled) { + return; + } + + const pendingAction = pendingActionRef.current; + pendingActionRef.current = null; + toastRef?.current?.closeToast(); + showToast( + RewardsToastOptions.loading( + strings('rewards.notifications_nudge.loading'), + strings('rewards.notifications_nudge.loading_description'), + ), + ); + Promise.resolve(pendingAction()).catch(() => { + toastRef?.current?.closeToast(); + showToast( + RewardsToastOptions.error( + strings('rewards.notifications_nudge.enable_error'), + ), + ); + }); + }, [ + areNotificationsEnabled, + enabled, + toastRef, + showToast, + RewardsToastOptions, + ]); + + useEffect(() => { + if (!enabled) { + appStateSubscriptionRef.current?.remove(); + appStateSubscriptionRef.current = null; + pendingActionRef.current = null; + } + }, [enabled]); + + useEffect( + () => () => { + appStateSubscriptionRef.current?.remove(); + }, + [], + ); + + return { + areNotificationsEnabled, + canPromptToEnableNotifications, + shouldPromptToEnableNotifications, + showEnableNotificationsNudge, + closeEnableNotificationsNudge, + runAfterNotificationsEnabled, + }; +} diff --git a/app/components/UI/Rewards/hooks/useRewardsToast.test.tsx b/app/components/UI/Rewards/hooks/useRewardsToast.test.tsx index f7db2fb7b6a..4f3ec45b6c7 100644 --- a/app/components/UI/Rewards/hooks/useRewardsToast.test.tsx +++ b/app/components/UI/Rewards/hooks/useRewardsToast.test.tsx @@ -1,5 +1,6 @@ import { renderHook, act } from '@testing-library/react-hooks'; -import { useContext } from 'react'; +import { render } from '@testing-library/react-native'; +import { useContext, type ReactElement } from 'react'; import { playNotification, NotificationMoment } from '../../../../util/haptics'; import { mockTheme } from '../../../../util/theme'; import useRewardsToast, { RewardsToastOptions } from './useRewardsToast'; @@ -20,6 +21,10 @@ jest.mock('../../../../util/haptics'); jest.mock('../../../../../locales/i18n', () => ({ strings: jest.fn((key: string) => { if (key === 'rewards.toast_dismiss') return 'Dismiss'; + if (key === 'rewards.notifications_nudge.title') return "Don't miss out"; + if (key === 'rewards.notifications_nudge.description') { + return 'Enable notifications to stay informed on campaigns'; + } return key; }), })); @@ -32,6 +37,29 @@ jest.mock('../../../../util/theme', () => { }; }); +jest.mock('../../../../images/rewards/notification.svg', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => + ReactActual.createElement(View, { testID: 'rewards-notification-svg' }), + }; +}); + +jest.mock('@metamask/design-system-react-native', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + Box: ({ children }: { children?: React.ReactNode }) => + ReactActual.createElement( + View, + { testID: 'rewards-nudge-start-accessory-box' }, + children, + ), + }; +}); + describe('useRewardsToast', () => { let mockShowToast: jest.Mock; let mockCloseToast: jest.Mock; @@ -79,6 +107,31 @@ describe('useRewardsToast', () => { }); expect(playNotification).toHaveBeenCalledWith(NotificationMoment.Success); }); + + it('strips hapticsType from payload passed to toastRef for enableNotificationsNudge', async () => { + const { result } = renderHook(() => useRewardsToast()); + const nudgeConfig = + result.current.RewardsToastOptions.enableNotificationsNudge({ + label: 'Turn on', + onPress: jest.fn(), + }); + + await act(async () => { + result.current.showToast(nudgeConfig); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(mockShowToast).toHaveBeenCalledWith( + expect.not.objectContaining({ hapticsType: expect.anything() }), + ); + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: ToastVariants.Plain, + hasNoTimeout: true, + }), + ); + expect(playNotification).toHaveBeenCalledWith(NotificationMoment.Warning); + }); }); describe('RewardsToastOptions configurations', () => { @@ -213,6 +266,122 @@ describe('useRewardsToast', () => { }); }); + it('returns loading configuration with title only', () => { + const { result } = renderHook(() => useRewardsToast()); + const config = result.current.RewardsToastOptions.loading('Loading...'); + + expect(config).toMatchObject({ + variant: ToastVariants.Plain, + hasNoTimeout: true, + hapticsType: NotificationMoment.Warning, + }); + expect(config.labelOptions).toEqual([ + { label: 'Loading...', isBold: true }, + ]); + expect(config.descriptionOptions).toBeUndefined(); + expect(config.closeButtonOptions).toMatchObject({ + variant: ButtonIconVariant.Icon, + iconName: IconName.Close, + }); + }); + + it('returns loading configuration with title and subtitle', () => { + const { result } = renderHook(() => useRewardsToast()); + const config = result.current.RewardsToastOptions.loading( + 'Loading...', + 'Please wait', + ); + + expect(config.labelOptions).toEqual([ + { label: 'Loading...', isBold: true }, + ]); + expect(config.descriptionOptions).toEqual({ description: 'Please wait' }); + }); + + it('calls closeToast when loading close button is pressed', () => { + const { result } = renderHook(() => useRewardsToast()); + const config = result.current.RewardsToastOptions.loading('Loading...'); + + config.closeButtonOptions?.onPress?.(); + + expect(mockCloseToast).toHaveBeenCalledTimes(1); + }); + + it('renders startAccessory for loading config', () => { + const { result } = renderHook(() => useRewardsToast()); + const config = result.current.RewardsToastOptions.loading('Loading...'); + + expect(config.startAccessory).toBeDefined(); + }); + + it('returns enableNotificationsNudge configuration with Plain variant', () => { + const { result } = renderHook(() => useRewardsToast()); + const onPress = jest.fn(); + const config = + result.current.RewardsToastOptions.enableNotificationsNudge({ + label: 'Turn on', + onPress, + }); + + expect(config).toMatchObject({ + variant: ToastVariants.Plain, + hasNoTimeout: true, + hapticsType: NotificationMoment.Warning, + linkButtonOptions: { label: 'Turn on', onPress }, + }); + expect(config.labelOptions).toEqual([ + { label: "Don't miss out", isBold: true }, + ]); + expect(config.descriptionOptions).toEqual({ + description: 'Enable notifications to stay informed on campaigns', + }); + expect(config.closeButtonOptions).toMatchObject({ + variant: ButtonIconVariant.Icon, + iconName: IconName.Close, + }); + }); + + it('passes linkButtonOptions through to enableNotificationsNudge config', () => { + const { result } = renderHook(() => useRewardsToast()); + const onPress = jest.fn(); + const config = + result.current.RewardsToastOptions.enableNotificationsNudge({ + label: 'Open settings', + onPress, + }); + + expect(config.linkButtonOptions).toEqual({ + label: 'Open settings', + onPress, + }); + }); + + it('renders startAccessory containing notification icon placeholder', () => { + const { result } = renderHook(() => useRewardsToast()); + const config = + result.current.RewardsToastOptions.enableNotificationsNudge({ + label: 'Turn on', + onPress: jest.fn(), + }); + + expect(config.startAccessory).toBeDefined(); + const { getByTestId } = render(config.startAccessory as ReactElement); + expect(getByTestId('rewards-notification-svg')).toBeDefined(); + }); + + it('calls closeToast when enableNotificationsNudge close button is pressed', () => { + const { result } = renderHook(() => useRewardsToast()); + const config = + result.current.RewardsToastOptions.enableNotificationsNudge({ + label: 'Turn on', + onPress: jest.fn(), + }); + + config.closeButtonOptions?.onPress?.(); + + expect(mockCloseToast).toHaveBeenCalledTimes(1); + }); + it('calls closeToast when close button is pressed', () => { const { result } = renderHook(() => useRewardsToast()); const config = result.current.RewardsToastOptions.success('Test Title'); @@ -346,6 +515,17 @@ describe('useRewardsToast', () => { expect(config.hasNoTimeout).toBe(false); }); + + it('uses persistent toast timeout for enableNotificationsNudge', () => { + const { result } = renderHook(() => useRewardsToast()); + const config = + result.current.RewardsToastOptions.enableNotificationsNudge({ + label: 'Turn on', + onPress: jest.fn(), + }); + + expect(config.hasNoTimeout).toBe(true); + }); }); describe('label and description formatting', () => { diff --git a/app/components/UI/Rewards/hooks/useRewardsToast.tsx b/app/components/UI/Rewards/hooks/useRewardsToast.tsx index 1c18401ecf3..db14b667bd8 100644 --- a/app/components/UI/Rewards/hooks/useRewardsToast.tsx +++ b/app/components/UI/Rewards/hooks/useRewardsToast.tsx @@ -1,9 +1,11 @@ -import { useCallback, useContext, useMemo } from 'react'; +import React, { useCallback, useContext, useMemo } from 'react'; +import { ActivityIndicator } from 'react-native'; import { ToastContext } from '../../../../component-library/components/Toast'; import { ButtonIconVariant, ToastDescriptionOptions, ToastLabelOptions, + ToastLinkButtonOptions, ToastOptions, ToastVariants, } from '../../../../component-library/components/Toast/Toast.types'; @@ -14,6 +16,9 @@ import { NotificationMoment, type HapticNotificationMoment, } from '../../../../util/haptics'; +import { strings } from '../../../../../locales/i18n'; +import RewardsNotificationIcon from '../../../../images/rewards/notification.svg'; +import { Box } from '@metamask/design-system-react-native'; export type RewardsToastOptions = ToastOptions & { hapticsType: HapticNotificationMoment; @@ -22,7 +27,11 @@ export type RewardsToastOptions = ToastOptions & { export interface RewardsToastConfig { success: (title: string, subtitle?: string) => RewardsToastOptions; error: (title: string, subtitle?: string) => RewardsToastOptions; + loading: (title: string, subtitle?: string) => RewardsToastOptions; entriesClosed: (title: string, subtitle?: string) => RewardsToastOptions; + enableNotificationsNudge: ( + linkButtonOptions: ToastLinkButtonOptions, + ) => RewardsToastOptions; } const getRewardsToastLabels = (title: string): ToastLabelOptions => { @@ -104,6 +113,26 @@ const useRewardsToast = (): { }, }, }), + loading: (title: string, subtitle?: string) => ({ + ...(REWARDS_TOASTS_DEFAULT_OPTIONS as RewardsToastOptions), + variant: ToastVariants.Plain, + hasNoTimeout: true, + hapticsType: NotificationMoment.Warning, + startAccessory: ( + + + + ), + labelOptions: getRewardsToastLabels(title), + descriptionOptions: getRewardsToastDescriptionLabels(subtitle), + closeButtonOptions: { + variant: ButtonIconVariant.Icon, + iconName: IconName.Close, + onPress: () => { + toastRef?.current?.closeToast(); + }, + }, + }), entriesClosed: (title: string, subtitle?: string) => ({ ...(REWARDS_TOASTS_DEFAULT_OPTIONS as RewardsToastOptions), variant: ToastVariants.Icon, @@ -122,11 +151,44 @@ const useRewardsToast = (): { }, }, }), + enableNotificationsNudge: ( + linkButtonOptions: ToastLinkButtonOptions, + ) => ({ + ...(REWARDS_TOASTS_DEFAULT_OPTIONS as RewardsToastOptions), + variant: ToastVariants.Plain, + hasNoTimeout: true, + hapticsType: NotificationMoment.Warning, + startAccessory: ( + + + + ), + labelOptions: getRewardsToastLabels( + strings('rewards.notifications_nudge.title'), + ), + descriptionOptions: getRewardsToastDescriptionLabels( + strings('rewards.notifications_nudge.description'), + ), + linkButtonOptions, + closeButtonOptions: { + variant: ButtonIconVariant.Icon, + iconName: IconName.Close, + onPress: () => { + toastRef?.current?.closeToast(); + }, + }, + }), }), [ theme.colors.success.default, theme.colors.error.default, theme.colors.icon.default, + theme.colors.warning.default, toastRef, ], ); diff --git a/app/components/UI/SettingsNotification/__snapshots__/index.test.tsx.snap b/app/components/UI/SettingsNotification/__snapshots__/index.test.tsx.snap deleted file mode 100644 index 05d92c4e81d..00000000000 --- a/app/components/UI/SettingsNotification/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,90 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`SettingsNotification should render correctly as notification 1`] = ` - - -  - - this is a notification - -`; - -exports[`SettingsNotification should render correctly as warning 1`] = ` - - -  - - this is a warning - -`; diff --git a/app/components/UI/SettingsNotification/index.test.tsx b/app/components/UI/SettingsNotification/index.test.tsx index 62a4d5849be..acce7596736 100644 --- a/app/components/UI/SettingsNotification/index.test.tsx +++ b/app/components/UI/SettingsNotification/index.test.tsx @@ -1,23 +1,30 @@ import React from 'react'; +import { Text } from 'react-native'; import { render } from '@testing-library/react-native'; import SettingsNotification from './'; describe('SettingsNotification', () => { - it('should render correctly as warning', () => { - const { toJSON } = render( + it('renders children in warning variant', () => { + const { getByTestId } = render( - {'this is a warning'} + this is a warning , ); - expect(toJSON()).toMatchSnapshot(); + + expect(getByTestId('settings-notification-label').props.children).toBe( + 'this is a warning', + ); }); - it('should render correctly as notification', () => { - const { toJSON } = render( + it('renders children in notification variant', () => { + const { getByTestId } = render( - {'this is a notification'} + this is a notification , ); - expect(toJSON()).toMatchSnapshot(); + + expect(getByTestId('settings-notification-label').props.children).toBe( + 'this is a notification', + ); }); }); diff --git a/app/components/UI/StyledButton/__snapshots__/index.test.tsx.snap b/app/components/UI/StyledButton/__snapshots__/index.test.tsx.snap deleted file mode 100644 index ff106165532..00000000000 --- a/app/components/UI/StyledButton/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,1035 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`StyledButton should render correctly on Android the button with type cancel 1`] = ` - -`; - -exports[`StyledButton should render correctly on Android the button with type confirm 1`] = ` - -`; - -exports[`StyledButton should render correctly on Android the button with type danger 1`] = ` - -`; - -exports[`StyledButton should render correctly on Android the button with type info 1`] = ` - -`; - -exports[`StyledButton should render correctly on Android the button with type inverse-transparent 1`] = ` - -`; - -exports[`StyledButton should render correctly on Android the button with type normal 1`] = ` - -`; - -exports[`StyledButton should render correctly on Android the button with type onOverlay 1`] = ` - -`; - -exports[`StyledButton should render correctly on Android the button with type orange 1`] = ` - -`; - -exports[`StyledButton should render correctly on Android the button with type secondary 1`] = ` - -`; - -exports[`StyledButton should render correctly on Android the button with type transparent 1`] = ` - -`; - -exports[`StyledButton should render correctly on iOS the button with type cancel 1`] = ` - - - -`; - -exports[`StyledButton should render correctly on iOS the button with type confirm 1`] = ` - - - -`; - -exports[`StyledButton should render correctly on iOS the button with type danger 1`] = ` - - - -`; - -exports[`StyledButton should render correctly on iOS the button with type info 1`] = ` - - - -`; - -exports[`StyledButton should render correctly on iOS the button with type inverse-transparent 1`] = ` - - - -`; - -exports[`StyledButton should render correctly on iOS the button with type normal 1`] = ` - - - -`; - -exports[`StyledButton should render correctly on iOS the button with type onOverlay 1`] = ` - - - -`; - -exports[`StyledButton should render correctly on iOS the button with type orange 1`] = ` - - - -`; - -exports[`StyledButton should render correctly on iOS the button with type secondary 1`] = ` - - - -`; - -exports[`StyledButton should render correctly on iOS the button with type transparent 1`] = ` - - - -`; diff --git a/app/components/UI/StyledButton/index.test.tsx b/app/components/UI/StyledButton/index.test.tsx index 68fb06c73aa..f604f389e25 100644 --- a/app/components/UI/StyledButton/index.test.tsx +++ b/app/components/UI/StyledButton/index.test.tsx @@ -20,23 +20,23 @@ describe('StyledButton', () => { buttonTypes.forEach((type) => { it(`should render correctly on iOS the button with type ${type}`, () => { - const { toJSON } = render( + const { getByRole } = render( , ); - expect(toJSON()).toMatchSnapshot(); + expect(getByRole('button')).toBeOnTheScreen(); }); }); buttonTypes.forEach((type) => { it(`should render correctly on Android the button with type ${type}`, () => { - const { toJSON } = render( + const { getByRole } = render( , ); - expect(toJSON()).toMatchSnapshot(); + expect(getByRole('button')).toBeOnTheScreen(); }); }); }); diff --git a/app/components/UI/Tabs/TabCountIcon/__snapshots__/index.test.tsx.snap b/app/components/UI/Tabs/TabCountIcon/__snapshots__/index.test.tsx.snap deleted file mode 100644 index 9d95d9bb11d..00000000000 --- a/app/components/UI/Tabs/TabCountIcon/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,34 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`TabCountIcon should render correctly 1`] = ` - - - 1 - - -`; diff --git a/app/components/UI/Tabs/TabCountIcon/index.test.tsx b/app/components/UI/Tabs/TabCountIcon/index.test.tsx index 89ff6fe900c..7c8c02f8a03 100644 --- a/app/components/UI/Tabs/TabCountIcon/index.test.tsx +++ b/app/components/UI/Tabs/TabCountIcon/index.test.tsx @@ -4,6 +4,7 @@ import configureMockStore from 'redux-mock-store'; import { render } from '@testing-library/react-native'; import { Provider } from 'react-redux'; import { ThemeContext, mockTheme } from '../../../../util/theme'; +import { BrowserViewSelectorsIDs } from '../../../Views/BrowserTab/BrowserView.testIds'; const mockStore = configureMockStore(); const initialState = { @@ -14,15 +15,16 @@ const initialState = { const store = mockStore(initialState); describe('TabCountIcon', () => { - it('should render correctly', () => { - // eslint-disable-next-line react/jsx-no-bind - const { toJSON } = render( + it('shows the tab count from Redux state', () => { + const { getByTestId } = render( , ); - expect(toJSON()).toMatchSnapshot(); + const countLabel = getByTestId(BrowserViewSelectorsIDs.TABS_NUMBER); + expect(countLabel).toBeOnTheScreen(); + expect(countLabel.props.children).toBe(1); }); }); diff --git a/app/components/UI/TokenImage/__snapshots__/index.test.tsx.snap b/app/components/UI/TokenImage/__snapshots__/index.test.tsx.snap deleted file mode 100644 index 15964de2db8..00000000000 --- a/app/components/UI/TokenImage/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,19 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`TokenImage should render correctly 1`] = ` - -`; diff --git a/app/components/UI/TokenImage/index.test.tsx b/app/components/UI/TokenImage/index.test.tsx deleted file mode 100644 index 3108a01f37d..00000000000 --- a/app/components/UI/TokenImage/index.test.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react-native'; -import TokenImage from './'; -import configureMockStore from 'redux-mock-store'; -import { Provider } from 'react-redux'; -import { backgroundState } from '../../../util/test/initial-root-state'; - -const mockStore = configureMockStore(); -const initialState = { - engine: { - backgroundState, - }, - settings: { - primaryCurrency: 'usd', - }, -}; -const store = mockStore(initialState); - -describe('TokenImage', () => { - it('should render correctly', () => { - const { toJSON } = render( - - - , - ); - expect(toJSON()).toMatchSnapshot(); - }); -}); diff --git a/app/components/Views/ActivityView/index.js b/app/components/Views/ActivityView/index.js index 02d27e59d6e..02f7a4abe07 100644 --- a/app/components/Views/ActivityView/index.js +++ b/app/components/Views/ActivityView/index.js @@ -19,6 +19,7 @@ import TextComponent, { TextVariant, } from '../../../component-library/components/Texts/Text'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { KnownCaipNamespace } from '@metamask/utils'; import { selectIsEvmNetworkSelected } from '../../../selectors/multichainNetworkController'; import { selectChainId } from '../../../selectors/networkController'; import { selectNetworkName } from '../../../selectors/networkInfos'; @@ -37,6 +38,7 @@ import RampOrdersList from '../../UI/Ramp/Aggregator/Views/OrdersList'; import { useCurrentNetworkInfo } from '../../hooks/useCurrentNetworkInfo'; import { NetworkType, + useNetworksByCustomNamespace, useNetworksByNamespace, } from '../../hooks/useNetworksByNamespace/useNetworksByNamespace'; import { useStyles } from '../../hooks/useStyles'; @@ -96,13 +98,20 @@ const ActivityView = () => { networkType: NetworkType.Popular, }); + const { areAllNetworksSelected: areAllEvmPopularNetworksEnabled } = + useNetworksByCustomNamespace({ + networkType: NetworkType.Popular, + namespace: KnownCaipNamespace.Eip155, + }); + const currentNetworkName = getNetworkInfo(0)?.networkName; const params = useParams(); const perpsEnabledFlag = useSelector(selectPerpsEnabledFlag); const isPerpsEnabled = useMemo( - () => perpsEnabledFlag && isEvmSelected, - [perpsEnabledFlag, isEvmSelected], + () => + perpsEnabledFlag && (isEvmSelected || areAllEvmPopularNetworksEnabled), + [perpsEnabledFlag, isEvmSelected, areAllEvmPopularNetworksEnabled], ); const predictEnabledFlag = useSelector(selectPredictEnabledFlag); const isPredictEnabled = useMemo( diff --git a/app/components/Views/ActivityView/index.test.tsx b/app/components/Views/ActivityView/index.test.tsx index f80014687c1..c1dc00d2289 100644 --- a/app/components/Views/ActivityView/index.test.tsx +++ b/app/components/Views/ActivityView/index.test.tsx @@ -131,13 +131,20 @@ jest.mock('../../../core/Engine', () => ({ }, })); +let mockAreAllEvmPopularNetworksEnabled = false; + jest.mock('../../hooks/useNetworksByNamespace/useNetworksByNamespace', () => ({ useNetworksByNamespace: () => ({ networks: [], + areAllNetworksSelected: false, selectNetwork: jest.fn(), selectCustomNetwork: jest.fn(), selectPopularNetwork: jest.fn(), }), + useNetworksByCustomNamespace: () => ({ + networks: [], + areAllNetworksSelected: mockAreAllEvmPopularNetworksEnabled, + }), NetworkType: { Popular: 'popular', Custom: 'custom', @@ -263,6 +270,7 @@ describe('ActivityView', () => { mockIsEvmSelected = true; mockPerpsEnabled = false; mockPredictEnabled = false; + mockAreAllEvmPopularNetworksEnabled = false; clearRenderedTabs(); mockRoute.params = {}; }); @@ -474,6 +482,18 @@ describe('ActivityView', () => { expect(getRenderedTabs()).toContain('perps'); }); + it('includes Perps tab when all popular EVM networks are enabled while on non-EVM', () => { + mockPerpsEnabled = true; + mockIsEvmSelected = false; + mockAreAllEvmPopularNetworksEnabled = true; + + const { getByTestId, queryByTestId } = renderComponent(mockInitialState); + + expect(getByTestId('tab-perps')).toBeOnTheScreen(); + expect(queryByTestId('perps-transactions-view')).toBeNull(); + expect(getRenderedTabs()).toContain('perps'); + }); + it('excludes Perps tab when feature flag is disabled', () => { mockPerpsEnabled = false; mockIsEvmSelected = true; @@ -486,6 +506,7 @@ describe('ActivityView', () => { it('excludes Perps tab on non-EVM network even with feature flag enabled', () => { mockPerpsEnabled = true; mockIsEvmSelected = false; + mockAreAllEvmPopularNetworksEnabled = false; renderComponent(mockInitialState); diff --git a/app/components/Views/Homepage/Sections/WhatsHappening/WhatsHappeningSection.test.tsx b/app/components/Views/Homepage/Sections/WhatsHappening/WhatsHappeningSection.test.tsx index ebaaa55c4a1..3b75c3cb825 100644 --- a/app/components/Views/Homepage/Sections/WhatsHappening/WhatsHappeningSection.test.tsx +++ b/app/components/Views/Homepage/Sections/WhatsHappening/WhatsHappeningSection.test.tsx @@ -138,10 +138,9 @@ describe('WhatsHappeningSection', () => { }); renderWithProvider(); fireEvent.press(screen.getByText(mockItem.title)); - expect(mockNavigate).toHaveBeenCalledWith( - Routes.WHATS_HAPPENING_DETAIL, - expect.objectContaining({ items: [mockItem], initialIndex: 0 }), - ); + expect(mockNavigate).toHaveBeenCalledWith(Routes.WHATS_HAPPENING_DETAIL, { + initialIndex: 0, + }); }); it('navigates to detail view at index 0 when ViewMore card is pressed', () => { @@ -153,10 +152,9 @@ describe('WhatsHappeningSection', () => { }); renderWithProvider(); fireEvent.press(screen.getByText(/view more/i)); - expect(mockNavigate).toHaveBeenCalledWith( - Routes.WHATS_HAPPENING_DETAIL, - expect.objectContaining({ items: [mockItem], initialIndex: 0 }), - ); + expect(mockNavigate).toHaveBeenCalledWith(Routes.WHATS_HAPPENING_DETAIL, { + initialIndex: 0, + }); }); it('navigates with correct index when second card is pressed', () => { @@ -173,12 +171,8 @@ describe('WhatsHappeningSection', () => { }); renderWithProvider(); fireEvent.press(screen.getByText(secondItem.title)); - expect(mockNavigate).toHaveBeenCalledWith( - Routes.WHATS_HAPPENING_DETAIL, - expect.objectContaining({ - items: [mockItem, secondItem], - initialIndex: 1, - }), - ); + expect(mockNavigate).toHaveBeenCalledWith(Routes.WHATS_HAPPENING_DETAIL, { + initialIndex: 1, + }); }); }); diff --git a/app/components/Views/Homepage/Sections/WhatsHappening/WhatsHappeningSection.tsx b/app/components/Views/Homepage/Sections/WhatsHappening/WhatsHappeningSection.tsx index 5152da79f80..1394aad4325 100644 --- a/app/components/Views/Homepage/Sections/WhatsHappening/WhatsHappeningSection.tsx +++ b/app/components/Views/Homepage/Sections/WhatsHappening/WhatsHappeningSection.tsx @@ -16,6 +16,7 @@ import { SectionRefreshHandle } from '../../types'; import { selectWhatsHappeningEnabled } from '../../../../../selectors/featureFlagController/whatsHappening'; import { strings } from '../../../../../../locales/i18n'; import Routes from '../../../../../constants/navigation/Routes'; +import { MAX_ITEMS_DISPLAYED } from './constants'; import { useWhatsHappening } from './hooks'; import { WhatsHappeningCard, WhatsHappeningCardSkeleton } from './components'; import useHomeViewedEvent, { @@ -24,8 +25,6 @@ import useHomeViewedEvent, { import { useSectionPerformance } from '../../hooks/useSectionPerformance'; import { WalletViewSelectorsIDs } from '../../../Wallet/WalletView.testIds'; -const MAX_ITEMS_DISPLAYED = 5; - const CARD_WIDTH = 280; const GAP = 12; @@ -91,14 +90,11 @@ const WhatsHappeningSection = forwardRef< const navigateToDetail = useCallback( (initialIndex: number) => { - // TODO: When WhatsHappeningDetailView is implemented, pass only { initialIndex } — the - // detail screen should call useWhatsHappening(); AiDigestController caches the response. navigation.navigate(Routes.WHATS_HAPPENING_DETAIL, { - items, initialIndex, }); }, - [navigation, items], + [navigation], ); const handleViewAll = useCallback(() => { diff --git a/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.test.tsx b/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.test.tsx index 5f650f5c8d3..b2decb5c8bf 100644 --- a/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.test.tsx +++ b/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.test.tsx @@ -40,6 +40,38 @@ describe('WhatsHappeningCard', () => { expect(screen.queryByText('Macro')).toBeNull(); }); + it('renders Bullish impact badge for positive impact', () => { + const item = { ...baseItem, impact: 'positive' as const }; + renderWithProvider(); + expect(screen.getByText('Bullish')).toBeOnTheScreen(); + }); + + it('renders Bearish impact badge for negative impact', () => { + const item = { ...baseItem, impact: 'negative' as const }; + renderWithProvider(); + expect(screen.getByText('Bearish')).toBeOnTheScreen(); + }); + + it('renders Neutral impact badge for neutral impact', () => { + const item = { ...baseItem, impact: 'neutral' as const }; + renderWithProvider(); + expect(screen.getByText('Neutral')).toBeOnTheScreen(); + }); + + it('does not render impact badge when impact is absent', () => { + const item = { ...baseItem, impact: undefined }; + renderWithProvider(); + expect(screen.queryByText('Bullish')).toBeNull(); + expect(screen.queryByText('Bearish')).toBeNull(); + expect(screen.queryByText('Neutral')).toBeNull(); + }); + + it('renders impact badge alongside category badge', () => { + renderWithProvider(); + expect(screen.getByText('Bullish')).toBeOnTheScreen(); + expect(screen.getByText('Macro')).toBeOnTheScreen(); + }); + it('renders related asset symbol pills', () => { renderWithProvider(); expect(screen.getByText('BTC')).toBeOnTheScreen(); diff --git a/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.tsx b/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.tsx index 40bdc4f590b..34ab48a84fb 100644 --- a/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.tsx +++ b/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.tsx @@ -13,6 +13,11 @@ import { import { strings } from '../../../../../../../locales/i18n'; import type { WhatsHappeningItem } from '../types'; import { formatShortDate } from '../util/formatDate'; +import { + getImpactLabel, + getImpactBackgroundClass, + getImpactTextColor, +} from '../util/impact'; interface WhatsHappeningCardProps { item: WhatsHappeningItem; @@ -37,18 +42,39 @@ const WhatsHappeningCard: React.FC = ({ )} > - {/* Category badge */} - {item.category && ( - - - {strings( - `homepage.sections.whats_happening_categories.${item.category}`, - )} - + {/* Impact + Category badges */} + {(item.impact || item.category) && ( + + {item.impact && ( + + + {getImpactLabel(item.impact)} + + + )} + {item.category && ( + + + {strings( + `homepage.sections.whats_happening_categories.${item.category}`, + )} + + + )} )} diff --git a/app/components/Views/Homepage/Sections/WhatsHappening/constants.ts b/app/components/Views/Homepage/Sections/WhatsHappening/constants.ts new file mode 100644 index 00000000000..62ac194b310 --- /dev/null +++ b/app/components/Views/Homepage/Sections/WhatsHappening/constants.ts @@ -0,0 +1 @@ +export const MAX_ITEMS_DISPLAYED = 5; diff --git a/app/components/Views/Homepage/Sections/WhatsHappening/util/impact.ts b/app/components/Views/Homepage/Sections/WhatsHappening/util/impact.ts new file mode 100644 index 00000000000..1b3de5ac10f --- /dev/null +++ b/app/components/Views/Homepage/Sections/WhatsHappening/util/impact.ts @@ -0,0 +1,44 @@ +import { TextColor } from '@metamask/design-system-react-native'; +import { strings } from '../../../../../../../locales/i18n'; +import type { WhatsHappeningItem } from '../types'; + +export const getImpactLabel = ( + impact: WhatsHappeningItem['impact'], +): string => { + switch (impact) { + case 'positive': + return strings('homepage.sections.whats_happening_impact.bullish'); + case 'negative': + return strings('homepage.sections.whats_happening_impact.bearish'); + default: + return strings('homepage.sections.whats_happening_impact.neutral'); + } +}; + +/** Returns just the background colour class for the impact badge. */ +export const getImpactBackgroundClass = ( + impact: WhatsHappeningItem['impact'], +): string => { + switch (impact) { + case 'positive': + return 'bg-success-muted'; + case 'negative': + return 'bg-error-muted'; + default: + return 'bg-muted'; + } +}; + +/** Returns the text colour token for the impact badge. */ +export const getImpactTextColor = ( + impact: WhatsHappeningItem['impact'], +): TextColor => { + switch (impact) { + case 'positive': + return TextColor.SuccessDefault; + case 'negative': + return TextColor.ErrorDefault; + default: + return TextColor.TextAlternative; + } +}; diff --git a/app/components/Views/Root/__snapshots__/index.test.tsx.snap b/app/components/Views/Root/__snapshots__/index.test.tsx.snap deleted file mode 100644 index 512752dfdfa..00000000000 --- a/app/components/Views/Root/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,3 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Root should render null while isTest is true and store is loading 1`] = `null`; diff --git a/app/components/Views/Root/index.test.tsx b/app/components/Views/Root/index.test.tsx index f40db80297b..36af0f1cdc2 100644 --- a/app/components/Views/Root/index.test.tsx +++ b/app/components/Views/Root/index.test.tsx @@ -47,6 +47,9 @@ jest.mock('../../../util/test/utils', () => ({ })); describe('Root', () => { + /** Must match `testID` on the `View` returned by `jest.mock('../../Nav/App')`. */ + const mockedAppTestId = 'mock-app'; + it('should initialize SecureKeychain', async () => { render(); @@ -66,9 +69,9 @@ describe('Root', () => { Object.defineProperty(testUtils, 'isTest', { value: true, writable: true }); }); - it('should render null while isTest is true and store is loading', () => { + it('does not mount Nav/App until the store gate clears when isTest is true', () => { Object.defineProperty(testUtils, 'isTest', { value: true, writable: true }); - const { toJSON } = render(); - expect(toJSON()).toMatchSnapshot(); + const { queryByTestId } = render(); + expect(queryByTestId(mockedAppTestId)).toBeNull(); }); }); diff --git a/app/components/Views/WhatsHappeningDetailView/WhatsHappeningDetailView.test.tsx b/app/components/Views/WhatsHappeningDetailView/WhatsHappeningDetailView.test.tsx new file mode 100644 index 00000000000..be5bc016e77 --- /dev/null +++ b/app/components/Views/WhatsHappeningDetailView/WhatsHappeningDetailView.test.tsx @@ -0,0 +1,139 @@ +import React from 'react'; +import { screen, fireEvent } from '@testing-library/react-native'; +import renderWithProvider from '../../../util/test/renderWithProvider'; +import WhatsHappeningDetailView from './WhatsHappeningDetailView'; + +const mockGoBack = jest.fn(); +const mockRefresh = jest.fn(); + +jest.mock('@react-navigation/native', () => { + const actual = jest.requireActual('@react-navigation/native'); + return { + ...actual, + useNavigation: () => ({ goBack: mockGoBack }), + useRoute: () => ({ params: { initialIndex: 0 } }), + }; +}); + +jest.mock('../Homepage/Sections/WhatsHappening/hooks', () => ({ + useWhatsHappening: jest.fn(() => ({ + items: [], + isLoading: false, + error: null, + refresh: mockRefresh, + })), +})); + +jest.mock('./utils/getRelatedAssetImageSource', () => ({ + getRelatedAssetImageSource: jest.fn(() => undefined), +})); + +jest.mock('../../UI/Ramp/hooks/useRampNavigation', () => ({ + useRampNavigation: () => ({ goToBuy: jest.fn() }), +})); + +jest.mock('../../UI/MarketInsights/utils/marketInsightsFormatting', () => ({ + formatRelativeTime: jest.fn(() => 'now'), + getUniqueSourcesByFavicon: jest.fn(() => []), +})); + +jest.mock( + '../../UI/MarketInsights/components/SourceLogoGroup', + () => 'SourceLogoGroup', +); + +const mockUseWhatsHappening = jest.requireMock( + '../Homepage/Sections/WhatsHappening/hooks', +).useWhatsHappening; + +const mockItem = { + id: 'trend-0', + title: 'The Federal Reserve pauses interest rates', + description: 'Reflecting the current economy.', + date: '2026-03-15T10:00:00.000Z', + category: 'macro' as const, + impact: 'positive' as const, + relatedAssets: [], + articles: [], +}; + +describe('WhatsHappeningDetailView', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the screen title', () => { + renderWithProvider(); + expect(screen.getByText("What's happening")).toBeOnTheScreen(); + }); + + it('renders skeleton carousel while loading', () => { + mockUseWhatsHappening.mockReturnValue({ + items: [], + isLoading: true, + error: null, + refresh: mockRefresh, + }); + renderWithProvider(); + expect( + screen.getByTestId('whats-happening-detail-skeleton'), + ).toBeOnTheScreen(); + expect(screen.queryByTestId('whats-happening-detail-carousel')).toBeNull(); + }); + + it('renders error state when fetch fails', () => { + mockUseWhatsHappening.mockReturnValue({ + items: [], + isLoading: false, + error: 'Network error', + refresh: mockRefresh, + }); + renderWithProvider(); + expect(screen.getByText(/unable to load/i)).toBeOnTheScreen(); + expect(screen.queryByTestId('whats-happening-detail-carousel')).toBeNull(); + }); + + it('calls refresh when error retry button is pressed', () => { + mockUseWhatsHappening.mockReturnValue({ + items: [], + isLoading: false, + error: 'Network error', + refresh: mockRefresh, + }); + renderWithProvider(); + fireEvent.press(screen.getByText('Retry')); + expect(mockRefresh).toHaveBeenCalledTimes(1); + }); + + it('renders the carousel with items when data is available', () => { + mockUseWhatsHappening.mockReturnValue({ + items: [mockItem], + isLoading: false, + error: null, + refresh: mockRefresh, + }); + renderWithProvider(); + expect( + screen.getByTestId('whats-happening-detail-carousel'), + ).toBeOnTheScreen(); + expect(screen.getByText(mockItem.title)).toBeOnTheScreen(); + }); + + it('does not show the skeleton or error when items are loaded', () => { + mockUseWhatsHappening.mockReturnValue({ + items: [mockItem], + isLoading: false, + error: null, + refresh: mockRefresh, + }); + renderWithProvider(); + expect(screen.queryByTestId('whats-happening-detail-skeleton')).toBeNull(); + expect(screen.queryByText(/unable to load/i)).toBeNull(); + }); + + it('calls navigation.goBack when back button is pressed', () => { + renderWithProvider(); + fireEvent.press(screen.getByTestId('whats-happening-detail-back-button')); + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/components/Views/WhatsHappeningDetailView/WhatsHappeningDetailView.tsx b/app/components/Views/WhatsHappeningDetailView/WhatsHappeningDetailView.tsx new file mode 100644 index 00000000000..97a32e15dfa --- /dev/null +++ b/app/components/Views/WhatsHappeningDetailView/WhatsHappeningDetailView.tsx @@ -0,0 +1,149 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { + Dimensions, + NativeScrollEvent, + NativeSyntheticEvent, + SafeAreaView, + ScrollView, +} from 'react-native'; +import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + Box, + ButtonIcon, + ButtonIconSize, + HeaderBase, + IconName, +} from '@metamask/design-system-react-native'; +import { strings } from '../../../../locales/i18n'; +import { useWhatsHappening } from '../Homepage/Sections/WhatsHappening/hooks'; +import { WhatsHappeningCardSkeleton } from '../Homepage/Sections/WhatsHappening/components'; +import { MAX_ITEMS_DISPLAYED } from '../Homepage/Sections/WhatsHappening/constants'; +import ErrorState from '../Homepage/components/ErrorState/ErrorState'; +import WhatsHappeningExpandedCard from './components/WhatsHappeningExpandedCard'; +import PageIndicator from './components/PageIndicator'; + +const { width: SCREEN_WIDTH } = Dimensions.get('window'); + +const HORIZONTAL_PADDING = 16; +const GAP = 12; +export const CARD_WIDTH = SCREEN_WIDTH - HORIZONTAL_PADDING * 2 - GAP; +const SNAP_INTERVAL = CARD_WIDTH + GAP; + +const SKELETON_KEYS = Array.from( + { length: MAX_ITEMS_DISPLAYED }, + (_, i) => `skeleton-${i}`, +); + +interface WhatsHappeningDetailParams { + initialIndex: number; +} + +const WhatsHappeningDetailView = () => { + const navigation = useNavigation(); + const tw = useTailwind(); + const route = + useRoute>(); + + const { initialIndex = 0 } = route.params; + + const { items, isLoading, error, refresh } = + useWhatsHappening(MAX_ITEMS_DISPLAYED); + + const [currentIndex, setCurrentIndex] = useState(initialIndex); + const scrollViewRef = useRef(null); + + useEffect(() => { + if (initialIndex > 0 && scrollViewRef.current && !isLoading) { + scrollViewRef.current.scrollTo({ + x: initialIndex * SNAP_INTERVAL, + animated: false, + }); + } + }, [initialIndex, isLoading]); + + const handleBackPress = useCallback(() => { + navigation.goBack(); + }, [navigation]); + + const handleScrollEnd = useCallback( + (event: NativeSyntheticEvent) => { + const offsetX = event.nativeEvent.contentOffset.x; + const index = Math.round(offsetX / SNAP_INTERVAL); + setCurrentIndex(Math.max(0, Math.min(index, items.length - 1))); + }, + [items.length], + ); + + const hasError = !isLoading && items.length === 0 && !!error; + + return ( + + + } + style={tw`p-4`} + twClassName="h-auto" + > + {strings('homepage.sections.whats_happening')} + + + + {isLoading ? ( + + {SKELETON_KEYS.map((key) => ( + + ))} + + ) : hasError ? ( + + ) : ( + <> + + {items.map((item) => ( + + ))} + + + + + )} + + + ); +}; + +export default WhatsHappeningDetailView; diff --git a/app/components/Views/WhatsHappeningDetailView/components/AssetRow.tsx b/app/components/Views/WhatsHappeningDetailView/components/AssetRow.tsx new file mode 100644 index 00000000000..7fc2de6e5e7 --- /dev/null +++ b/app/components/Views/WhatsHappeningDetailView/components/AssetRow.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { + AvatarToken, + AvatarTokenSize, + Box, + BoxAlignItems, + BoxFlexDirection, + BoxJustifyContent, + ButtonBase, + ButtonBaseSize, + FontWeight, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import type { RelatedAsset } from '@metamask/ai-controllers'; +import { getRelatedAssetImageSource } from '../utils/getRelatedAssetImageSource'; + +interface AssetRowProps { + asset: RelatedAsset; + actionLabel: string; + accessibilityLabel: string; + onAction: () => void; +} + +/** + * Shared layout for a single asset row (logo + symbol + action button). + * Used by TokenRow (Buy) and PerpsRow (Trade); each wrapper supplies its + * own hook logic and passes the resolved label and handler here. + */ +const AssetRow: React.FC = ({ + asset, + actionLabel, + accessibilityLabel, + onAction, +}) => { + const rawImageSource = getRelatedAssetImageSource(asset); + const imageSource = Array.isArray(rawImageSource) + ? (rawImageSource[0] as { uri?: string } | undefined) + : (rawImageSource as number | { uri?: string } | undefined); + + return ( + + + + + + {asset.symbol} + + + + {actionLabel} + + + + ); +}; + +export default AssetRow; diff --git a/app/components/Views/WhatsHappeningDetailView/components/PageIndicator.test.tsx b/app/components/Views/WhatsHappeningDetailView/components/PageIndicator.test.tsx new file mode 100644 index 00000000000..8c7699f17d2 --- /dev/null +++ b/app/components/Views/WhatsHappeningDetailView/components/PageIndicator.test.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { screen } from '@testing-library/react-native'; +import renderWithProvider from '../../../../util/test/renderWithProvider'; +import PageIndicator from './PageIndicator'; + +describe('PageIndicator', () => { + it('renders nothing when count is 1', () => { + const { toJSON } = renderWithProvider( + , + ); + expect(toJSON()).toBeNull(); + }); + + it('renders nothing when count is 0', () => { + const { toJSON } = renderWithProvider( + , + ); + expect(toJSON()).toBeNull(); + }); + + it('renders count dots when count > 1', () => { + renderWithProvider(); + const activeDots = screen.queryAllByTestId('page-indicator-dot-active'); + const inactiveDots = screen.queryAllByTestId('page-indicator-dot'); + expect(activeDots.length + inactiveDots.length).toBe(3); + }); + + it('marks the correct dot as active', () => { + renderWithProvider(); + expect(screen.queryAllByTestId('page-indicator-dot-active')).toHaveLength( + 1, + ); + expect(screen.queryAllByTestId('page-indicator-dot')).toHaveLength(2); + }); + + it('marks the first dot active when activeIndex is 0', () => { + renderWithProvider(); + expect(screen.queryAllByTestId('page-indicator-dot-active')).toHaveLength( + 1, + ); + expect(screen.queryAllByTestId('page-indicator-dot')).toHaveLength(3); + }); +}); diff --git a/app/components/Views/WhatsHappeningDetailView/components/PageIndicator.tsx b/app/components/Views/WhatsHappeningDetailView/components/PageIndicator.tsx new file mode 100644 index 00000000000..314d0b765c3 --- /dev/null +++ b/app/components/Views/WhatsHappeningDetailView/components/PageIndicator.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + Box, + BoxFlexDirection, + BoxAlignItems, + BoxJustifyContent, +} from '@metamask/design-system-react-native'; + +interface PageIndicatorProps { + count: number; + activeIndex: number; +} + +const PageIndicator: React.FC = ({ + count, + activeIndex, +}) => { + const tw = useTailwind(); + + if (count <= 1) return null; + + return ( + + {Array.from({ length: count }, (_, index) => ( + + ))} + + ); +}; + +export default PageIndicator; diff --git a/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.test.tsx b/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.test.tsx new file mode 100644 index 00000000000..d5cc96b1dcd --- /dev/null +++ b/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.test.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { screen, fireEvent } from '@testing-library/react-native'; +import renderWithProvider from '../../../../util/test/renderWithProvider'; +import PerpsRow from './PerpsRow'; +import Routes from '../../../../constants/navigation/Routes'; +import type { RelatedAsset } from '@metamask/ai-controllers'; + +const mockNavigate = jest.fn(); + +jest.mock('@react-navigation/native', () => { + const actual = jest.requireActual('@react-navigation/native'); + return { + ...actual, + useNavigation: () => ({ navigate: mockNavigate }), + }; +}); + +jest.mock('../utils/getRelatedAssetImageSource', () => ({ + getRelatedAssetImageSource: jest.fn(() => undefined), +})); + +const perpsOnlyAsset: RelatedAsset = { + sourceAssetId: 'tsla', + symbol: 'TSLA', + name: 'Tesla', + caip19: [], + hlPerpsMarket: ['xyz:TSLA'], +}; + +const dualAsset: RelatedAsset = { + sourceAssetId: 'bitcoin', + symbol: 'BTC', + name: 'Bitcoin', + caip19: ['eip155:1/slip44:0'], + hlPerpsMarket: ['BTC'], +}; + +describe('PerpsRow', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the asset symbol', () => { + renderWithProvider(); + expect(screen.getByText('TSLA')).toBeOnTheScreen(); + }); + + it('renders the Trade button', () => { + renderWithProvider(); + expect(screen.getByText('Trade')).toBeOnTheScreen(); + }); + + it('navigates to PerpsMarketDetails with minimal market payload on Trade press', () => { + renderWithProvider(); + fireEvent.press(screen.getByText('Trade')); + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_DETAILS, + params: expect.objectContaining({ + market: { symbol: 'xyz:TSLA', name: 'Tesla' }, + }), + }); + }); + + it('uses first hlPerpsMarket entry as the market symbol', () => { + renderWithProvider(); + fireEvent.press(screen.getByText('Trade')); + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_DETAILS, + params: expect.objectContaining({ + market: { symbol: 'BTC', name: 'Bitcoin' }, + }), + }); + }); + + it('does not navigate when hlPerpsMarket is empty', () => { + const assetNoPerps: RelatedAsset = { + ...perpsOnlyAsset, + hlPerpsMarket: [], + }; + renderWithProvider(); + fireEvent.press(screen.getByText('Trade')); + expect(mockNavigate).not.toHaveBeenCalled(); + }); +}); diff --git a/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.tsx b/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.tsx new file mode 100644 index 00000000000..7bf75357504 --- /dev/null +++ b/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.tsx @@ -0,0 +1,45 @@ +import React, { useCallback } from 'react'; +import { useNavigation, NavigationProp } from '@react-navigation/native'; +import { PERPS_EVENT_VALUE } from '@metamask/perps-controller'; +import type { RelatedAsset } from '@metamask/ai-controllers'; +import type { PerpsNavigationParamList } from '../../../UI/Perps/types/navigation'; +import Routes from '../../../../constants/navigation/Routes'; +import { strings } from '../../../../../locales/i18n'; +import AssetRow from './AssetRow'; + +interface PerpsRowProps { + asset: RelatedAsset; +} + +/** + * A single row in the Perps section of the expanded What's Happening card. + * Displays the asset logo and symbol with a Trade button that navigates to + * the Perps market details view. Extracted as its own component so hooks can + * be called per-asset (hooks cannot be called inside a loop). + */ +const PerpsRow: React.FC = ({ asset }) => { + const navigation = useNavigation>(); + const hlPerpsMarket = asset.hlPerpsMarket?.[0]; + + const handleTrade = useCallback(() => { + if (!hlPerpsMarket) return; + navigation.navigate(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_DETAILS, + params: { + market: { symbol: hlPerpsMarket, name: asset.name }, + source: PERPS_EVENT_VALUE.SOURCE.HOME_SECTION, + }, + }); + }, [navigation, hlPerpsMarket, asset.name]); + + return ( + + ); +}; + +export default PerpsRow; diff --git a/app/components/Views/WhatsHappeningDetailView/components/TokenRow.test.tsx b/app/components/Views/WhatsHappeningDetailView/components/TokenRow.test.tsx new file mode 100644 index 00000000000..741562f2fc1 --- /dev/null +++ b/app/components/Views/WhatsHappeningDetailView/components/TokenRow.test.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { screen, fireEvent } from '@testing-library/react-native'; +import renderWithProvider from '../../../../util/test/renderWithProvider'; +import TokenRow from './TokenRow'; +import type { RelatedAsset } from '@metamask/ai-controllers'; + +const mockGoToBuy = jest.fn(); + +jest.mock('../../../UI/Ramp/hooks/useRampNavigation', () => ({ + useRampNavigation: () => ({ goToBuy: mockGoToBuy }), +})); + +jest.mock('../utils/getRelatedAssetImageSource', () => ({ + getRelatedAssetImageSource: jest.fn(() => undefined), +})); + +const btcAsset: RelatedAsset = { + sourceAssetId: 'bitcoin', + symbol: 'BTC', + name: 'Bitcoin', + caip19: ['eip155:1/slip44:0'], +}; + +const perpsOnlyAsset: RelatedAsset = { + sourceAssetId: 'tsla', + symbol: 'TSLA', + name: 'Tesla', + caip19: [], + hlPerpsMarket: ['xyz:TSLA'], +}; + +describe('TokenRow', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the asset symbol', () => { + renderWithProvider(); + expect(screen.getByText('BTC')).toBeOnTheScreen(); + }); + + it('renders the Buy button', () => { + renderWithProvider(); + expect(screen.getByText('Buy')).toBeOnTheScreen(); + }); + + it('calls goToBuy with the first caip19 identifier on Buy press', () => { + renderWithProvider(); + fireEvent.press(screen.getByText('Buy')); + expect(mockGoToBuy).toHaveBeenCalledWith({ + assetId: 'eip155:1/slip44:0', + }); + }); + + it('calls goToBuy with assetId undefined when caip19 is empty', () => { + renderWithProvider(); + fireEvent.press(screen.getByText('Buy')); + expect(mockGoToBuy).toHaveBeenCalledWith({ assetId: undefined }); + }); +}); diff --git a/app/components/Views/WhatsHappeningDetailView/components/TokenRow.tsx b/app/components/Views/WhatsHappeningDetailView/components/TokenRow.tsx new file mode 100644 index 00000000000..de0076fd2ab --- /dev/null +++ b/app/components/Views/WhatsHappeningDetailView/components/TokenRow.tsx @@ -0,0 +1,35 @@ +import React, { useCallback } from 'react'; +import type { RelatedAsset } from '@metamask/ai-controllers'; +import { strings } from '../../../../../locales/i18n'; +import { useRampNavigation } from '../../../UI/Ramp/hooks/useRampNavigation'; +import AssetRow from './AssetRow'; + +interface TokenRowProps { + asset: RelatedAsset; +} + +/** + * A single row in the Tokens section of the expanded What's Happening card. + * Displays the token logo, symbol, and a Buy button that navigates to the + * Ramp buy flow. Extracted as its own component so hooks can be called + * per-asset (hooks cannot be called inside a loop). + */ +const TokenRow: React.FC = ({ asset }) => { + const { goToBuy } = useRampNavigation(); + + const handleBuy = useCallback(() => { + const assetId = asset.caip19?.[0]; + goToBuy({ assetId }); + }, [goToBuy, asset.caip19]); + + return ( + + ); +}; + +export default TokenRow; diff --git a/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.test.tsx b/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.test.tsx new file mode 100644 index 00000000000..b4a978e2ee0 --- /dev/null +++ b/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.test.tsx @@ -0,0 +1,193 @@ +import React from 'react'; +import { screen, fireEvent } from '@testing-library/react-native'; +import renderWithProvider from '../../../../util/test/renderWithProvider'; +import WhatsHappeningExpandedCard from './WhatsHappeningExpandedCard'; +import type { WhatsHappeningItem } from '../../Homepage/Sections/WhatsHappening/types'; +import Routes from '../../../../constants/navigation/Routes'; + +const mockNavigate = jest.fn(); +const mockGoToBuy = jest.fn(); + +jest.mock('@react-navigation/native', () => { + const actual = jest.requireActual('@react-navigation/native'); + return { + ...actual, + useNavigation: () => ({ navigate: mockNavigate }), + }; +}); + +jest.mock('../utils/getRelatedAssetImageSource', () => ({ + getRelatedAssetImageSource: jest.fn(() => undefined), +})); + +jest.mock('../../../UI/Ramp/hooks/useRampNavigation', () => ({ + useRampNavigation: () => ({ goToBuy: mockGoToBuy }), +})); + +jest.mock('../../../UI/MarketInsights/utils/marketInsightsFormatting', () => ({ + formatRelativeTime: jest.fn(() => 'now'), + getUniqueSourcesByFavicon: jest.fn(() => []), +})); + +jest.mock( + '../../../UI/MarketInsights/components/SourceLogoGroup', + () => 'SourceLogoGroup', +); + +const CARD_WIDTH = 320; + +const tokenAsset = { + sourceAssetId: 'bitcoin', + symbol: 'BTC', + name: 'Bitcoin', + caip19: ['eip155:1/slip44:0'], + hlPerpsMarket: undefined, +}; + +const perpsOnlyAsset = { + sourceAssetId: 'tsla', + symbol: 'TSLA', + name: 'Tesla', + caip19: [], + hlPerpsMarket: ['xyz:TSLA'], +}; + +const dualAsset = { + sourceAssetId: 'eth', + symbol: 'ETH', + name: 'Ethereum', + caip19: ['eip155:1/slip44:60'], + hlPerpsMarket: ['ETH'], +}; + +const baseItem: WhatsHappeningItem = { + id: 'trend-0', + title: 'The Federal Reserve pauses interest rates', + description: 'Reflecting the current economy.', + date: '2026-03-15T10:00:00.000Z', + category: 'macro', + impact: 'positive', + relatedAssets: [], + articles: [], +}; + +describe('WhatsHappeningExpandedCard', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the title and description', () => { + renderWithProvider( + , + ); + expect(screen.getByText(baseItem.title)).toBeOnTheScreen(); + expect(screen.getByText(baseItem.description)).toBeOnTheScreen(); + }); + + it('renders the impact badge for positive impact', () => { + renderWithProvider( + , + ); + expect(screen.getByText('Bullish')).toBeOnTheScreen(); + }); + + it('renders Neutral badge when impact is explicitly neutral', () => { + const item = { ...baseItem, impact: 'neutral' as const }; + renderWithProvider( + , + ); + expect(screen.getByText('Neutral')).toBeOnTheScreen(); + }); + + it('does not render an impact badge when impact is undefined', () => { + const item = { ...baseItem, impact: undefined }; + renderWithProvider( + , + ); + expect(screen.queryByText('Neutral')).toBeNull(); + expect(screen.queryByText('Bullish')).toBeNull(); + expect(screen.queryByText('Bearish')).toBeNull(); + }); + + it('renders Tokens section when assets have caip19', () => { + const item = { ...baseItem, relatedAssets: [tokenAsset] }; + renderWithProvider( + , + ); + expect(screen.getByText('Tokens')).toBeOnTheScreen(); + expect(screen.getByText('BTC')).toBeOnTheScreen(); + expect(screen.getByText('Buy')).toBeOnTheScreen(); + }); + + it('does not render Tokens section when no assets have caip19', () => { + const item = { ...baseItem, relatedAssets: [perpsOnlyAsset] }; + renderWithProvider( + , + ); + expect(screen.queryByText('Tokens')).toBeNull(); + expect(screen.queryByText('Buy')).toBeNull(); + }); + + it('renders Perps section when assets have hlPerpsMarket', () => { + const item = { ...baseItem, relatedAssets: [perpsOnlyAsset] }; + renderWithProvider( + , + ); + expect(screen.getByText('Perps')).toBeOnTheScreen(); + expect(screen.getByText('TSLA')).toBeOnTheScreen(); + expect(screen.getByText('Trade')).toBeOnTheScreen(); + }); + + it('does not render Perps section when no assets have hlPerpsMarket', () => { + const item = { ...baseItem, relatedAssets: [tokenAsset] }; + renderWithProvider( + , + ); + expect(screen.queryByText('Perps')).toBeNull(); + expect(screen.queryByText('Trade')).toBeNull(); + }); + + it('renders both Tokens and Perps sections when there are separate token and perps-only assets', () => { + const item = { ...baseItem, relatedAssets: [tokenAsset, perpsOnlyAsset] }; + renderWithProvider( + , + ); + expect(screen.getByText('Tokens')).toBeOnTheScreen(); + expect(screen.getByText('Perps')).toBeOnTheScreen(); + expect(screen.getByText('Buy')).toBeOnTheScreen(); + expect(screen.getByText('Trade')).toBeOnTheScreen(); + }); + + it('does not duplicate a dual asset (caip19 + hlPerpsMarket) into the Perps section', () => { + const item = { ...baseItem, relatedAssets: [dualAsset] }; + renderWithProvider( + , + ); + expect(screen.getByText('Tokens')).toBeOnTheScreen(); + expect(screen.getByText('Buy')).toBeOnTheScreen(); + expect(screen.queryByText('Perps')).toBeNull(); + expect(screen.queryByText('Trade')).toBeNull(); + }); + + it('renders neither section when relatedAssets is empty', () => { + renderWithProvider( + , + ); + expect(screen.queryByText('Tokens')).toBeNull(); + expect(screen.queryByText('Perps')).toBeNull(); + }); + + it('Trade button navigates to PerpsMarketDetails', () => { + const item = { ...baseItem, relatedAssets: [perpsOnlyAsset] }; + renderWithProvider( + , + ); + fireEvent.press(screen.getByText('Trade')); + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_DETAILS, + params: expect.objectContaining({ + market: { symbol: 'xyz:TSLA', name: 'Tesla' }, + }), + }); + }); +}); diff --git a/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.tsx b/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.tsx new file mode 100644 index 00000000000..044a65e72eb --- /dev/null +++ b/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.tsx @@ -0,0 +1,203 @@ +import React, { useMemo, useState } from 'react'; +import { Pressable, ScrollView } from 'react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + BoxJustifyContent, + FontWeight, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import type { MarketInsightsSource } from '@metamask/ai-controllers'; +import type { WhatsHappeningItem } from '../../Homepage/Sections/WhatsHappening/types'; +import { strings } from '../../../../../locales/i18n'; +import { + getImpactLabel, + getImpactBackgroundClass, + getImpactTextColor, +} from '../../Homepage/Sections/WhatsHappening/util/impact'; +import { + formatRelativeTime, + getUniqueSourcesByFavicon, +} from '../../../UI/MarketInsights/utils/marketInsightsFormatting'; +import SourceLogoGroup from '../../../UI/MarketInsights/components/SourceLogoGroup'; +import PerpsRow from './PerpsRow'; +import TokenRow from './TokenRow'; +import WhatsHappeningSourcesBottomSheet from './WhatsHappeningSourcesBottomSheet'; + +interface WhatsHappeningExpandedCardProps { + item: WhatsHappeningItem; + cardWidth: number; +} + +const WhatsHappeningExpandedCard: React.FC = ({ + item, + cardWidth, +}) => { + const tw = useTailwind(); + const [sourcesVisible, setSourcesVisible] = useState(false); + + const impactLabel = getImpactLabel(item.impact); + const impactBgClass = getImpactBackgroundClass(item.impact); + const impactTextColor = getImpactTextColor(item.impact); + + const uniqueSources = useMemo(() => { + const sources: MarketInsightsSource[] = item.articles.map((article) => ({ + name: article.source, + type: 'news' as const, + url: article.url || article.source, + })); + return getUniqueSourcesByFavicon(sources); + }, [item.articles]); + + const sourceLabel = useMemo(() => { + const first = uniqueSources[0]; + if (!first) return null; + const remaining = Math.max(0, uniqueSources.length - 1); + return remaining > 0 ? `${first.name} +${remaining}` : first.name; + }, [uniqueSources]); + + return ( + + + + {/* Impact badge — only rendered when impact is explicitly set */} + {item.impact && ( + + + {impactLabel} + + + )} + + {/* Title */} + + {item.title} + + + {/* Description */} + {item.description && ( + + {item.description} + + )} + + {/* Tokens section — only assets with a purchasable CAIP-19 identifier */} + {item.relatedAssets.some((asset) => asset.caip19?.length) && ( + + + {strings('homepage.sections.tokens')} + + + {item.relatedAssets + .filter((asset) => asset.caip19?.length) + .map((asset) => ( + + ))} + + )} + + {/* Perps section — only assets that are perps-only (hlPerpsMarket set, no caip19 token) */} + {item.relatedAssets.some( + (asset) => asset.hlPerpsMarket?.length && !asset.caip19?.length, + ) && ( + + + {strings('homepage.sections.perps')} + + + {item.relatedAssets + .filter( + (asset) => + asset.hlPerpsMarket?.length && !asset.caip19?.length, + ) + .map((asset) => ( + + ))} + + )} + + {/* Sources trigger */} + {uniqueSources.length > 0 && ( + <> + + + setSourcesVisible(true)} + accessibilityRole="button" + > + {({ pressed }) => ( + + + + {sourceLabel ? ( + + {sourceLabel} + + ) : null} + + + {item.date ? ( + + {formatRelativeTime(item.date, { nowLabel: 'now' })} + + ) : null} + + )} + + + )} + + + + {sourcesVisible && ( + setSourcesVisible(false)} + articles={item.articles} + /> + )} + + ); +}; + +export default WhatsHappeningExpandedCard; diff --git a/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningSourcesBottomSheet.test.tsx b/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningSourcesBottomSheet.test.tsx new file mode 100644 index 00000000000..b86e82ede8c --- /dev/null +++ b/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningSourcesBottomSheet.test.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import { Linking } from 'react-native'; +import { screen, fireEvent } from '@testing-library/react-native'; +import renderWithProvider from '../../../../util/test/renderWithProvider'; +import WhatsHappeningSourcesBottomSheet from './WhatsHappeningSourcesBottomSheet'; + +jest.mock( + '../../../../component-library/components/BottomSheets/BottomSheet', + () => { + const ReactLib = jest.requireActual('react'); + const { View: MockView } = jest.requireActual('react-native'); + + return ReactLib.forwardRef( + ( + { children }: { children: React.ReactNode }, + ref: React.Ref<{ + onOpenBottomSheet: () => void; + onCloseBottomSheet: () => void; + }>, + ) => { + ReactLib.useImperativeHandle(ref, () => ({ + onOpenBottomSheet: jest.fn(), + onCloseBottomSheet: jest.fn(), + })); + return {children}; + }, + ); + }, +); + +jest.mock( + '../../../../component-library/components/BottomSheets/BottomSheetHeader', + () => { + const { View: MockView } = jest.requireActual('react-native'); + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); + }, +); + +jest.mock('../../../UI/MarketInsights/utils/marketInsightsFormatting', () => ({ + isSafeUrl: jest.fn(() => true), + formatRelativeTime: jest.fn(() => '2h ago'), + getFaviconUrl: jest.fn((url: string) => `https://favicon/${url}`), +})); + +const mockOpenURL = jest.spyOn(Linking, 'openURL').mockResolvedValue(undefined); +const mockIsSafeUrl = jest.requireMock( + '../../../UI/MarketInsights/utils/marketInsightsFormatting', +).isSafeUrl; + +const articles = [ + { + title: 'Fed pauses rate hikes', + url: 'https://coindesk.com/fed-pauses', + source: 'coindesk.com', + date: '2026-03-15T10:00:00.000Z', + }, + { + title: 'Bitcoin ETF sees record inflows', + url: 'https://cointelegraph.com/btc-etf', + source: 'cointelegraph.com', + date: '2026-03-15T09:00:00.000Z', + }, +]; + +describe('WhatsHappeningSourcesBottomSheet', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockIsSafeUrl.mockReturnValue(true); + }); + + it('renders one row per article', () => { + renderWithProvider( + , + ); + expect(screen.getByText('coindesk.com')).toBeOnTheScreen(); + expect(screen.getByText('cointelegraph.com')).toBeOnTheScreen(); + }); + + it('opens the article URL when a row is pressed and URL is safe', () => { + renderWithProvider( + , + ); + fireEvent.press(screen.getByText('coindesk.com')); + expect(mockOpenURL).toHaveBeenCalledWith('https://coindesk.com/fed-pauses'); + }); + + it('does not open the URL when isSafeUrl returns false', () => { + mockIsSafeUrl.mockReturnValue(false); + renderWithProvider( + , + ); + fireEvent.press(screen.getByText('coindesk.com')); + expect(mockOpenURL).not.toHaveBeenCalled(); + }); + + it('renders the sheet title', () => { + renderWithProvider( + , + ); + expect(screen.getByText('News sources')).toBeOnTheScreen(); + }); + + it('renders no article rows when articles array is empty', () => { + renderWithProvider( + , + ); + expect(screen.queryByText('coindesk.com')).toBeNull(); + }); +}); diff --git a/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningSourcesBottomSheet.tsx b/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningSourcesBottomSheet.tsx new file mode 100644 index 00000000000..9f6a48a63e4 --- /dev/null +++ b/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningSourcesBottomSheet.tsx @@ -0,0 +1,66 @@ +import React, { useCallback, useRef } from 'react'; +import { Linking } from 'react-native'; +import { ScrollView } from 'react-native-gesture-handler'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + FontWeight, + Text, + TextVariant, +} from '@metamask/design-system-react-native'; +import type { Article } from '@metamask/ai-controllers'; +import BottomSheet, { + BottomSheetRef, +} from '../../../../component-library/components/BottomSheets/BottomSheet'; +import BottomSheetHeader from '../../../../component-library/components/BottomSheets/BottomSheetHeader'; +import { strings } from '../../../../../locales/i18n'; +import ArticleRow from '../../../UI/MarketInsights/components/ArticleRow'; +import { isSafeUrl } from '../../../UI/MarketInsights/utils/marketInsightsFormatting'; + +interface WhatsHappeningSourcesBottomSheetProps { + onClose: () => void; + articles: Article[]; +} + +const WhatsHappeningSourcesBottomSheet: React.FC< + WhatsHappeningSourcesBottomSheetProps +> = ({ onClose, articles }) => { + const tw = useTailwind(); + const bottomSheetRef = useRef(null); + + const handleSourcePress = useCallback((url: string) => { + if (isSafeUrl(url)) { + Linking.openURL(url); + } + }, []); + + return ( + + + + {strings('market_insights.sources_title')} + + + + + {articles.map((article, index) => ( + + ))} + + + ); +}; + +export default WhatsHappeningSourcesBottomSheet; diff --git a/app/components/Views/WhatsHappeningDetailView/components/index.ts b/app/components/Views/WhatsHappeningDetailView/components/index.ts new file mode 100644 index 00000000000..fbcca56e40d --- /dev/null +++ b/app/components/Views/WhatsHappeningDetailView/components/index.ts @@ -0,0 +1,2 @@ +export { default as WhatsHappeningExpandedCard } from './WhatsHappeningExpandedCard'; +export { default as PageIndicator } from './PageIndicator'; diff --git a/app/components/Views/WhatsHappeningDetailView/index.ts b/app/components/Views/WhatsHappeningDetailView/index.ts new file mode 100644 index 00000000000..b105def8161 --- /dev/null +++ b/app/components/Views/WhatsHappeningDetailView/index.ts @@ -0,0 +1 @@ +export { default } from './WhatsHappeningDetailView'; diff --git a/app/components/Views/WhatsHappeningDetailView/utils/getRelatedAssetImageSource.test.ts b/app/components/Views/WhatsHappeningDetailView/utils/getRelatedAssetImageSource.test.ts new file mode 100644 index 00000000000..4a47ffb81fd --- /dev/null +++ b/app/components/Views/WhatsHappeningDetailView/utils/getRelatedAssetImageSource.test.ts @@ -0,0 +1,152 @@ +import { getRelatedAssetImageSource } from './getRelatedAssetImageSource'; + +// Mock image requires as numbers (React Native bundler assigns numbers to require() results) +jest.mock('../../../../images/image-icons', () => ({ + __esModule: true, + default: { + BTC: 100, + ETH: 123, + TRX: 456, + SOL: 789, + SVG_ICON: () => null, + STRING_ICON: 'string-path', + }, +})); + +const PERPS_ICONS_BASE = + 'https://raw.githubusercontent.com/MetaMask/metamask-perps-assets/main/icons/'; + +jest.mock('../../../UI/Perps/utils/marketUtils', () => ({ + getAssetIconUrls: jest.fn((symbol: string) => { + if (!symbol) return null; + if (symbol.includes(':')) { + const [dex, assetSymbol] = symbol.split(':'); + return { + primary: `${PERPS_ICONS_BASE}hip3:${dex.toLowerCase()}_${assetSymbol.toUpperCase()}.svg`, + fallback: `${PERPS_ICONS_BASE}${dex.toLowerCase()}:${assetSymbol.toUpperCase()}.svg`, + }; + } + return { + primary: `${PERPS_ICONS_BASE}${symbol.toUpperCase()}.svg`, + fallback: `${PERPS_ICONS_BASE}${symbol.toUpperCase()}.svg`, + }; + }), +})); + +jest.mock( + '../../../UI/Perps/components/PerpsTokenLogo/PerpsAssetBgConfig', + () => ({ + K_PREFIX_ASSETS: new Set(['KPEPE', 'KBONK']), + }), +); + +describe('getRelatedAssetImageSource', () => { + describe('CAIP-19 path (highest priority — regular crypto tokens)', () => { + it('uses bundled icon when symbol matches image-icons', () => { + const result = getRelatedAssetImageSource({ + name: 'Ethereum', + symbol: 'ETH', + caip19: ['eip155:1/slip44:60'], + sourceAssetId: 'ethereum', + hlPerpsMarket: ['ETH'], // present but must NOT trigger Perps SVG path + }); + + expect(result).toBe(123); + expect(typeof result).toBe('number'); // bundled PNG, not an SVG URI object + }); + + it('ignores hlPerpsMarket when caip19 is populated', () => { + // BTC has hlPerpsMarket but also has caip19 — must use CAIP-19 path + const result = getRelatedAssetImageSource({ + name: 'Bitcoin', + symbol: 'BTC', + caip19: ['bip122:000000000019d6689c085ae165831e93/slip44:0'], + sourceAssetId: 'bitcoin', + hlPerpsMarket: ['BTC'], + }); + + // Bundled BTC icon, not a Perps SVG URI + expect(result).toBe(100); + expect(typeof result).toBe('number'); + }); + + it('returns wallet CDN URI when symbol has no bundled icon', () => { + const result = getRelatedAssetImageSource({ + name: 'Some Token', + symbol: 'UNKNOWN', + caip19: ['eip155:1/erc20:0xABCDEF'], + sourceAssetId: 'some-token', + }); + + expect(result).toEqual({ + uri: expect.stringContaining('static.cx.metamask.io'), + }); + }); + }); + + describe('Perps path via hlPerpsMarket (only when caip19 is empty)', () => { + it('uses Perps primary SVG for a plain HL market id', () => { + // Hypothetical BTC with no caip19 (purely Perps context) + const result = getRelatedAssetImageSource({ + name: 'Bitcoin', + symbol: 'BTC', + caip19: [], + sourceAssetId: 'bitcoin', + hlPerpsMarket: ['BTC'], + }); + + expect(result).toEqual({ uri: `${PERPS_ICONS_BASE}BTC.svg` }); + }); + + it('uses Perps primary SVG for HIP-3 synthetic asset (xyz:TSLA format)', () => { + const result = getRelatedAssetImageSource({ + name: 'Tesla', + symbol: 'TSLA', + caip19: [], + sourceAssetId: 'tsla', + hlPerpsMarket: ['xyz:TSLA'], + }); + + expect(result).toEqual({ + uri: `${PERPS_ICONS_BASE}hip3:xyz_TSLA.svg`, + }); + }); + + it('skips Perps path when caip19 is populated even if hlPerpsMarket is set', () => { + const result = getRelatedAssetImageSource({ + name: 'Ethereum', + symbol: 'ETH', + caip19: ['eip155:1/slip44:60'], + sourceAssetId: 'ethereum', + hlPerpsMarket: ['ETH'], + }); + + // Must be bundled PNG, not SVG URI + expect(typeof result).toBe('number'); + }); + }); + + describe('symbol-only fallback', () => { + it('returns bundled icon by symbol when caip19 is empty and no hlPerpsMarket', () => { + const result = getRelatedAssetImageSource({ + name: 'Ethereum', + symbol: 'ETH', + caip19: [], + sourceAssetId: 'ethereum', + }); + + expect(result).toBe(123); + }); + + it('returns undefined when no caip19, no hlPerpsMarket, and unknown symbol', () => { + const result = getRelatedAssetImageSource({ + name: 'Some Token', + symbol: 'UNKNOWN', + caip19: [], + sourceAssetId: 'some-token', + }); + + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/app/components/Views/WhatsHappeningDetailView/utils/getRelatedAssetImageSource.ts b/app/components/Views/WhatsHappeningDetailView/utils/getRelatedAssetImageSource.ts new file mode 100644 index 00000000000..ff652ffd33b --- /dev/null +++ b/app/components/Views/WhatsHappeningDetailView/utils/getRelatedAssetImageSource.ts @@ -0,0 +1,49 @@ +import type { RelatedAsset } from '@metamask/ai-controllers'; +import { isNonEvmChainId } from '@metamask/bridge-controller'; +import { CaipAssetType, parseCaipAssetType } from '@metamask/utils'; +import type { ImageSourcePropType } from 'react-native'; +import { getTokenIconUrl, getTokenImageSource } from '../../../UI/Bridge/utils'; +import { getAssetIconUrls } from '../../../UI/Perps/utils/marketUtils'; +import { K_PREFIX_ASSETS } from '../../../UI/Perps/components/PerpsTokenLogo/PerpsAssetBgConfig'; + +/** + * Image source for a market-overview `RelatedAsset`. + * + * Resolution order: CAIP-19 wallet CDN + bundled PNG (regular crypto tokens) → + * Perps SVG via `hlPerpsMarket` when `caip19` is empty (synthetic-only assets + * like `xyz:TSLA`) → bundled icon by symbol. + * + * `hlPerpsMarket` is NOT consulted when `caip19` is populated because regular + * crypto tokens (BTC, ETH) carry it too, and Perps CDN only serves SVGs that + * `AvatarToken` cannot render remotely. + */ +export const getRelatedAssetImageSource = ( + asset: RelatedAsset, +): ImageSourcePropType | undefined => { + // 1. Wallet CDN via CAIP-19 (PNG — works with AvatarToken) + const firstCaip = asset.caip19?.[0]; + if (firstCaip) { + try { + const { chainId } = parseCaipAssetType(firstCaip as CaipAssetType); + const cdnUrl = getTokenIconUrl( + firstCaip as CaipAssetType, + isNonEvmChainId(chainId), + ); + return getTokenImageSource(asset.symbol, cdnUrl); + } catch { + // Invalid or unsupported CAIP-19 string — fall through + } + } + + // 2. Perps SVG for assets with no CAIP-19 (e.g. xyz:TSLA via hlPerpsMarket) + const firstHlPerpsMarket = asset.hlPerpsMarket?.[0]; + if (firstHlPerpsMarket && !asset.caip19?.length) { + const urls = getAssetIconUrls(firstHlPerpsMarket, K_PREFIX_ASSETS); + if (urls) { + return { uri: urls.primary }; + } + } + + // 3. Bundled icons only (symbol lookup) + return getTokenImageSource(asset.symbol, undefined); +}; diff --git a/app/components/hooks/useBlockExplorer.test.ts b/app/components/hooks/useBlockExplorer.test.ts index 53490066498..c0b7256cab5 100644 --- a/app/components/hooks/useBlockExplorer.test.ts +++ b/app/components/hooks/useBlockExplorer.test.ts @@ -1,3 +1,4 @@ +import { RpcEndpointType } from '@metamask/network-controller'; import { useNavigation } from '@react-navigation/native'; import Routes from '../../constants/navigation/Routes'; import { backgroundState } from '../../util/test/initial-root-state'; @@ -457,4 +458,68 @@ describe('useBlockExplorer', () => { }); }); }); + + describe('NetworkController block explorer for token chain', () => { + const stateWithEthereumSelectedAndGnosisConfigured = { + ...mockInitialState, + engine: { + ...mockInitialState.engine, + backgroundState: { + ...mockInitialState.engine.backgroundState, + NetworkController: mockNetworkState( + { + chainId: '0x1', + id: 'mainnet', + type: RpcEndpointType.Infura, + nickname: 'Ethereum Mainnet', + ticker: 'ETH', + blockExplorerUrl: 'https://etherscan.io', + }, + { + chainId: '0x64', + id: 'gnosis', + nickname: 'Gnosis Chain', + ticker: 'xDAI', + blockExplorerUrl: 'https://gnosisscan.io', + }, + ), + }, + }, + }; + + beforeEach(() => { + const reactRedux = + jest.requireMock('react-redux'); + jest + .spyOn(reactRedux, 'useSelector') + .mockImplementation((fn) => + fn(stateWithEthereumSelectedAndGnosisConfigured), + ); + }); + + afterEach(() => { + const reactRedux = + jest.requireMock('react-redux'); + jest.mocked(reactRedux.useSelector).mockRestore(); + }); + + it('resolves Gnosis block explorer from network configurations when Ethereum is selected', () => { + const { result } = renderHookWithProvider(() => useBlockExplorer()); + const tokenChainId = '0x64'; + const address = '0x0000000000000000000000000000000000000001'; + + expect(result.current.getBlockExplorerBaseUrl(tokenChainId)).toBe( + 'https://gnosisscan.io', + ); + expect(result.current.getBlockExplorerUrl(address, tokenChainId)).toBe( + `https://gnosisscan.io/address/${address}`, + ); + }); + + it('resolves block explorer name from configured explorer URL for that chain', () => { + const { result } = renderHookWithProvider(() => useBlockExplorer()); + const name = result.current.getBlockExplorerName('0x64'); + expect(name).toMatch(/gnosis/i); + }); + }); }); diff --git a/app/components/hooks/useBlockExplorer.ts b/app/components/hooks/useBlockExplorer.ts index 651acc3856a..676b50370da 100644 --- a/app/components/hooks/useBlockExplorer.ts +++ b/app/components/hooks/useBlockExplorer.ts @@ -2,6 +2,7 @@ import { useCallback } from 'react'; import { RPC } from '../../constants/network'; import { findBlockExplorerForRpc, + findBlockExplorerUrlForChain, getBlockExplorerName as getBlockExplorerNameFromUrl, getHexEvmChainId, } from '../../util/networks'; @@ -95,6 +96,13 @@ const useBlockExplorer = (chainId?: string) => { if (baseUrl) { return `${baseUrl}/address/${address}`; } + const explorerFromNetworkConfig = findBlockExplorerUrlForChain( + currentChainId, + networkConfigurations, + ); + if (explorerFromNetworkConfig) { + return `${explorerFromNetworkConfig}/address/${address}`; + } } // For RPC networks, try to find custom block explorer @@ -167,6 +175,13 @@ const useBlockExplorer = (chainId?: string) => { if (baseUrl) { return baseUrl; } + const explorerFromNetworkConfig = findBlockExplorerUrlForChain( + currentChainId, + networkConfigurations, + ); + if (explorerFromNetworkConfig) { + return explorerFromNetworkConfig; + } } // For RPC networks, try to find custom block explorer @@ -231,6 +246,13 @@ const useBlockExplorer = (chainId?: string) => { if (baseUrl) { return getBlockExplorerNameFromUrl(baseUrl); } + const explorerFromNetworkConfig = findBlockExplorerUrlForChain( + currentChainId, + networkConfigurations, + ); + if (explorerFromNetworkConfig) { + return getBlockExplorerNameFromUrl(explorerFromNetworkConfig); + } } // For RPC networks, try to find custom block explorer diff --git a/app/controllers/perps/providers/HyperLiquidProvider.test.ts b/app/controllers/perps/providers/HyperLiquidProvider.test.ts index d0efdd99d71..d899d2d1ba6 100644 --- a/app/controllers/perps/providers/HyperLiquidProvider.test.ts +++ b/app/controllers/perps/providers/HyperLiquidProvider.test.ts @@ -1226,7 +1226,7 @@ describe('HyperLiquidProvider', () => { ); }); - it('validates price requirement before attempting order placement', async () => { + it('retries with adjusted USD when price-less order hits $10 minimum (uses fetched price from allMids)', async () => { // Create provider with PUMP in the asset mapping provider = createTestProvider({ initialAssetMapping: [ @@ -1256,24 +1256,38 @@ describe('HyperLiquidProvider', () => { isBuy: true, size: '2553', orderType: 'market', + // No currentPrice: provider fetches live price (0.003918) and uses it + // for both validation and the $10-minimum retry path. }; + const mockOrder = jest + .fn() + .mockRejectedValueOnce( + new Error('Order must have minimum value of $10'), + ) + .mockResolvedValueOnce({ + status: 'ok', + response: { + data: { + statuses: [ + { filled: { oid: 123, totalSz: '2553', avgPx: '0.004' } }, + ], + }, + }, + }); + mockClientService.getExchangeClient = jest.fn().mockReturnValue({ ...createMockExchangeClient(), - order: jest - .fn() - .mockRejectedValueOnce( - new Error('Order must have minimum value of $10'), - ), + order: mockOrder, }); const result = await provider.placeOrder(orderParams); - expect(result.success).toBe(false); - expect(result.error).toBe(PERPS_ERROR_CODES.ORDER_PRICE_REQUIRED); - expect( - mockClientService.getExchangeClient().order, - ).not.toHaveBeenCalled(); + // The live price is fetched → validation passes → order is submitted → + // first call hits $10 minimum → retry uses fetched price to compute + // adjusted usdAmount → second call succeeds. + expect(result.success).toBe(true); + expect(mockOrder).toHaveBeenCalledTimes(2); }); it('closes a position successfully', async () => { @@ -2812,6 +2826,7 @@ describe('HyperLiquidProvider', () => { }); it('handles missing price data', async () => { + mockSubscriptionService.getCachedPrice.mockReturnValueOnce(undefined); ( mockClientService.getInfoClient().allMids as jest.Mock ).mockResolvedValueOnce({}); @@ -2825,8 +2840,10 @@ describe('HyperLiquidProvider', () => { const result = await provider.placeOrder(orderParams); + // allMids returns {} so #getOrFetchPrice parses price as 0, which is + // invalid. The error surfaces from #getAssetInfo before validation runs. expect(result.success).toBe(false); - expect(result.error).toContain(PERPS_ERROR_CODES.ORDER_PRICE_REQUIRED); + expect(result.error).toContain('Invalid price for BTC: 0'); }); it('handles missing position in close operation', async () => { @@ -3545,19 +3562,45 @@ describe('HyperLiquidProvider', () => { expect(result.error).toContain('Failed to update leverage'); }); - it('fails market order without current price or usdAmount', async () => { + it('succeeds with market order without current price or usdAmount (uses fetched price)', async () => { + // The provider now fetches the live price before validation so callers + // that intentionally omit currentPrice (e.g. flipPosition) work correctly. const orderParams: OrderParams = { symbol: 'BTC', isBuy: true, size: '0.1', orderType: 'market', - // No currentPrice or usdAmount provided - should fail validation + // No currentPrice or usdAmount: provider fetches live price (50000) }; const result = await provider.placeOrder(orderParams); - expect(result.success).toBe(false); - expect(result.error).toContain(PERPS_ERROR_CODES.ORDER_PRICE_REQUIRED); + expect(result.success).toBe(true); + expect(mockClientService.getExchangeClient().order).toHaveBeenCalled(); + }); + + it('placeOrder validates against fetched price when params omit currentPrice (flipPosition path)', async () => { + // Simulate the exact OrderParams shape that TradingService.flipPosition + // builds: symbol + isBuy + size + orderType + leverage, no price fields. + const flipOrderParams: OrderParams = { + symbol: 'BTC', + isBuy: false, // flipping long → short + size: '1', // 2× the 0.5 BTC position + orderType: 'market', + leverage: 10, + // currentPrice, usdAmount, price intentionally absent + }; + + const result = await provider.placeOrder(flipOrderParams); + + // Live price (50000) is fetched from allMids → validation passes + // (0.1 BTC × $50 000 = $5 000 >> $10 minimum) → order executes. + expect(result.success).toBe(true); + expect( + mockClientService.getExchangeClient().order, + ).toHaveBeenCalledWith( + expect.objectContaining({ orders: expect.any(Array) }), + ); }); it('handles order with custom slippage', async () => { diff --git a/app/controllers/perps/providers/HyperLiquidProvider.ts b/app/controllers/perps/providers/HyperLiquidProvider.ts index 16ca796fff4..1a6f906c69b 100644 --- a/app/controllers/perps/providers/HyperLiquidProvider.ts +++ b/app/controllers/perps/providers/HyperLiquidProvider.ts @@ -3543,6 +3543,9 @@ export class HyperLiquidProvider implements PerpsProvider { * @returns A promise that resolves to the result. */ async placeOrder(params: OrderParams, retryCount = 0): Promise { + // Hoisted so the retry path in the catch block can use the fetched price + // even when the caller (e.g. flipPosition) omits currentPrice from params. + let effectivePrice: number | undefined; try { this.#deps.debugLogger.log('Placing order via HyperLiquid SDK:', params); @@ -3557,38 +3560,18 @@ export class HyperLiquidProvider implements PerpsProvider { throw new Error(validation.error); } - // Validate order at provider level (enforces USD validation rules) - await this.#validateOrderBeforePlacement(params); - - // Ensure provider is ready for trading (includes signing operations) - await this.#ensureReadyForTrading(); - - // Debug: Log asset map state before order placement - const allMapKeys = Array.from(this.#symbolToAssetId.keys()); - const hip3Keys = allMapKeys.filter((key) => key.includes(':')); - const assetExists = this.#symbolToAssetId.has(params.symbol); - this.#deps.debugLogger.log('Asset map state at order time', { - requestedCoin: params.symbol, - assetExistsInMap: assetExists, - totalAssetsInMap: this.#symbolToAssetId.size, - hip3AssetsCount: hip3Keys.length, - hip3AssetsSample: hip3Keys.slice(0, 10), - hip3Enabled: this.#hip3Enabled, - allowlistMarkets: this.#allowlistMarkets, - blocklistMarkets: this.#blocklistMarkets, - }); - // Extract DEX name for API calls (main DEX = null) const { dex: dexName } = parseAssetName(params.symbol); - // 1. Get asset info and current price + // 1. Get asset info and current price before validation so price-less + // callers (e.g. flipPosition) can validate against the live fetched price. const { assetInfo, currentPrice, meta } = await this.#getAssetInfo({ symbol: params.symbol, dexName, }); - // Allow override with UI-provided price (optimization to avoid API call) - const effectivePrice = + // Allow override with UI-provided price (optimization to avoid API call). + effectivePrice = params.currentPrice && params.currentPrice > 0 ? params.currentPrice : currentPrice; @@ -3601,6 +3584,35 @@ export class HyperLiquidProvider implements PerpsProvider { }); } + // Validate order at provider level (enforces USD validation rules). + // Pass effectivePrice so price-less market orders (e.g. flipPosition) + // validate against the live fetched price instead of failing with + // ORDER_PRICE_REQUIRED. + await this.#validateOrderBeforePlacement({ + ...params, + currentPrice: effectivePrice, + }); + + // Ensure provider is ready for trading (includes signing operations). + // Kept after validation so invalid orders never trigger signature prompts + // (builder-fee approval, DEX abstraction enablement, etc.). + await this.#ensureReadyForTrading(); + + // Debug: Log asset map state before order placement + const allMapKeys = Array.from(this.#symbolToAssetId.keys()); + const hip3Keys = allMapKeys.filter((key) => key.includes(':')); + const assetExists = this.#symbolToAssetId.has(params.symbol); + this.#deps.debugLogger.log('Asset map state at order time', { + requestedCoin: params.symbol, + assetExistsInMap: assetExists, + totalAssetsInMap: this.#symbolToAssetId.size, + hip3AssetsCount: hip3Keys.length, + hip3AssetsSample: hip3Keys.slice(0, 10), + hip3Enabled: this.#hip3Enabled, + allowlistMarkets: this.#allowlistMarkets, + blocklistMarkets: this.#blocklistMarkets, + }); + // 2. Calculate final position size with USD reconciliation const { finalPositionSize } = calculateFinalPositionSize({ usdAmount: params.usdAmount, @@ -3706,10 +3718,13 @@ export class HyperLiquidProvider implements PerpsProvider { // USD-based order: adjust the USD amount directly originalValue = params.usdAmount; adjustedUsdAmount = (parseFloat(params.usdAmount) * 1.015).toFixed(2); - } else if (params.currentPrice) { - // Size-based order: calculate USD from size and adjust + } else if (effectivePrice) { + // Size-based order: calculate USD from size and adjust. + // Use the hoisted effectivePrice (fetched live price) so callers that + // omit currentPrice (e.g. flipPosition) can still recover from the + // $10-minimum edge case. const sizeValue = parseFloat(params.size); - const estimatedUsd = sizeValue * params.currentPrice; + const estimatedUsd = sizeValue * effectivePrice; originalValue = `${estimatedUsd.toFixed(2)} (calculated from size ${params.size})`; adjustedUsdAmount = (estimatedUsd * 1.015).toFixed(2); } else { diff --git a/app/selectors/earnController/earn/index.test.ts b/app/selectors/earnController/earn/index.test.ts index 3ebc7a503a0..99664c1b070 100644 --- a/app/selectors/earnController/earn/index.test.ts +++ b/app/selectors/earnController/earn/index.test.ts @@ -59,6 +59,12 @@ jest.mock('../../../components/UI/Earn/selectors/featureFlags', () => ({ prioritizeFlagsByEnv: jest.fn().mockReturnValue(true), })); +jest.mock('../../../selectors/multichainAccounts/accounts', () => ({ + selectSelectedInternalAccountByScope: () => () => + jest.requireActual('../../../util/test/accountsControllerTestUtils') + .internalAccount2, +})); + const MOCK_ROOT_STATE_WITH_EARN_CONTROLLER = mockEarnControllerRootState(); const MOCK_RATE = { price: 0.99, diff --git a/app/selectors/multichain/multichain.test.ts b/app/selectors/multichain/multichain.test.ts index 135824c0e05..e7c9d4eddbe 100644 --- a/app/selectors/multichain/multichain.test.ts +++ b/app/selectors/multichain/multichain.test.ts @@ -687,7 +687,13 @@ describe('MultichainNonEvm Selectors', () => { let mockSelectedGroupAccounts: InternalAccount[] | undefined; jest.doMock('./evm', () => ({ - selectAccountTokensAcrossChains: () => mockEvmTokensByChain, + selectAccountTokensAcrossChainsForAddress: () => mockEvmTokensByChain, + })); + + jest.doMock('../multichainAccounts/accounts', () => ({ + selectSelectedInternalAccountByScope: () => () => ({ + address: '0xMockEvmAddress', + }), })); jest.doMock('../multichainAccounts/accountTreeController', () => ({ @@ -724,7 +730,13 @@ describe('MultichainNonEvm Selectors', () => { let mockSelectedGroupAccounts: InternalAccount[] | undefined; jest.doMock('./evm', () => ({ - selectAccountTokensAcrossChains: () => mockEvmTokensByChain, + selectAccountTokensAcrossChainsForAddress: () => mockEvmTokensByChain, + })); + + jest.doMock('../multichainAccounts/accounts', () => ({ + selectSelectedInternalAccountByScope: () => () => ({ + address: '0xMockEvmAddress', + }), })); jest.doMock('../multichainAccounts/accountTreeController', () => ({ diff --git a/app/selectors/multichain/multichain.ts b/app/selectors/multichain/multichain.ts index 4f45d88bc80..0835f9012db 100644 --- a/app/selectors/multichain/multichain.ts +++ b/app/selectors/multichain/multichain.ts @@ -45,7 +45,9 @@ import { import { TokenI } from '../../components/UI/Tokens/types'; import { createSelector } from 'reselect'; import { selectSelectedAccountGroupInternalAccounts } from '../multichainAccounts/accountTreeController'; -import { selectAccountTokensAcrossChains } from './evm'; +import { selectAccountTokensAcrossChainsForAddress } from './evm'; +import { selectSelectedInternalAccountByScope } from '../multichainAccounts/accounts'; +import { EVM_SCOPE } from '../../components/UI/Earn/constants/networks'; import { MULTICHAIN_ACCOUNT_TYPE_TO_MAINNET } from '../../core/Multichain/constants'; import { isTronSpecialAsset } from '../../core/Multichain/utils'; import { @@ -321,13 +323,28 @@ export const selectMultichainTokenListForAccountsAnyChain = }, ); +/** + * EVM tokens resolved from the EVM-scoped account within the selected account + * group, regardless of the currently active network. This prevents non-EVM + * active networks (e.g. TRON) from causing `selectSelectedInternalAccount` to + * resolve to a non-EVM address that has no EVM balance data. + */ +const selectAccountTokensAcrossChainsForEvmScope = createSelector( + (state: RootState) => state, + selectSelectedInternalAccountByScope, + (state, accountByScope) => { + const evmAddress = accountByScope(EVM_SCOPE)?.address; + return selectAccountTokensAcrossChainsForAddress(state, evmAddress); + }, +); + /** * Unified selector: EVM tokens (native + ERC20) for the selected EVM address * plus non-EVM tokens (e.g., TRX) across all accounts in the selected account group. * Returns a map keyed by chainId (hex for EVM, CAIP-2 for non-EVM) to TokenI[]. */ export const selectAccountTokensAcrossChainsUnified = createDeepEqualSelector( - selectAccountTokensAcrossChains, + selectAccountTokensAcrossChainsForEvmScope, selectSelectedAccountGroupInternalAccounts, (state: RootState) => state, (evmTokensByChain, selectedGroupAccounts, state) => { diff --git a/bitrise.yml b/bitrise.yml index b3ae62da808..ed92f518c7d 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3597,9 +3597,9 @@ trigger_map: # Disable auto RC generation # - push_branch: release/* # pipeline: pr_rc_rwy_pipeline - - push_branch: main - pipeline: expo_dev_pipeline - - tag: 'qa-*' - pipeline: create_qa_builds_pipeline - - tag: 'v*.*.*' - pipeline: create_qa_builds_pipeline + # - push_branch: main + # pipeline: expo_dev_pipeline + # - tag: 'qa-*' + # pipeline: create_qa_builds_pipeline + # - tag: 'v*.*.*' + # pipeline: create_qa_builds_pipeline diff --git a/locales/languages/de.json b/locales/languages/de.json index 8c07828e0b3..46be6a42eed 100644 --- a/locales/languages/de.json +++ b/locales/languages/de.json @@ -479,7 +479,7 @@ "biometric_authentication_cancelled_title": "Biometrische Einrichtung fehlgeschlagen", "biometric_authentication_cancelled_description": "Bitte richten Sie die biometrische Authentifizierung in den Einstellungen erneut ein.", "biometric_authentication_cancelled_button": "Bestätigen", - "biometric_changed": "Biometric Changed", + "biometric_changed": "Biometrische Daten geändert", "biometric_changed_alert_desc": "Ihre Biometrie wurde geändert. Aktivieren Sie bitte die biometrischen Daten in den Einstellungen erneut.", "biometric_changed_alert_confirm": "Bestätigen" }, @@ -634,7 +634,8 @@ "trade": "Trade", "settings": "Einstellungen", "rewards": "Belohnungen", - "trending": "Entdecken" + "trending": "Entdecken", + "money": "Money" }, "drawer": { "send_button": "Senden", @@ -2446,6 +2447,12 @@ } } }, + "payment": { + "change_payment_method": "Change Payment Method", + "add_funds": "Gelder hinzufügen", + "add": "Hinzufügen", + "predict_balance": "Predict balance" + }, "order": { "available": "Verfügbar", "cashed_out": "Ausgezahlt", @@ -3314,6 +3321,8 @@ "invalid_number_range": "Ungültige Zahl. Geben Sie eine Zahl zwischen 1 und %{maxSafeChainId} ein", "hide_zero_balance_tokens_title": "Tokens ohne Guthaben verbergen", "hide_zero_balance_tokens_desc": "Vermeiden Sie, dass Token ohne Guthaben in Ihrer Token-Liste angezeigt werden.", + "haptic_feedback_title": "Haptic feedback", + "haptic_feedback_desc": "Feel subtle vibrations for confirmations, errors, and interactions. Disable to reduce haptic stimuli.", "token_detection_title": "Tokens automatisch erkennen", "token_detection_description": "Wir verwenden APIs von Drittanbietern, um neue Token zu erkennen und anzuzeigen, die in Ihr Wallet gesendet werden. Schalten Sie dies ab, wenn Sie keine Daten von diesen Diensten beziehen möchten.", "theme_button_text": "Theme ändern", @@ -3457,7 +3466,7 @@ "title": "Headless buy", "loading": "Preparing the buy flow…", "no_session": "Headless session is no longer active.", - "cancel": "Cancel" + "cancel": "Stornieren" } }, "request_feature": "Eine Funktion anfragen", @@ -3572,6 +3581,39 @@ "title": "Karte", "reset_onboarding_description": "Setzen Sie den Onboarding-Status der Karte zurück, um den Onboarding-Prozess von vorn zu beginnen.", "reset_onboarding_button": "Onboarding-Status zurücksetzen" + }, + "haptics": { + "title": "Haptics", + "description": "Preview haptic feedback on a physical device. This bypasses in-app reduced-haptics and the remote kill switch so you can evaluate each pattern. Simulators often do not play haptics.", + "catalog_impacts_heading": "Catalog impact moments", + "notifications_heading": "Notification patterns", + "selection_heading": "Selection", + "notification_success_button": "Notification — Success", + "notification_error_button": "Notification — Error", + "notification_warning_button": "Notification — Warning", + "selection_button": "Selection", + "raw_impacts_heading": "Raw Expo impact styles", + "raw_impacts_desc": "Direct expo-haptics impact styles for comparison with the mapped catalog moments above.", + "impacts": { + "quick_amount_selection": "Impact — Quick amount selection", + "slider_tick": "Impact — Slider tick", + "edge_gesture_engage": "Impact — Edge gesture engage", + "page_navigation": "Impact — Page navigation", + "slider_grip": "Impact — Slider grip", + "tab_change": "Impact — Tab change", + "primary_cta": "Impact — Primary CTA", + "pull_to_refresh_engage": "Impact — Pull to refresh (engage)", + "pull_to_refresh": "Impact — Pull to refresh (release)", + "chart_crosshair": "Impact — Chart crosshair", + "follow_toggle": "Impact — Follow toggle" + }, + "raw_styles": { + "light": "Raw impact — Light", + "medium": "Raw impact — Medium", + "heavy": "Raw impact — Heavy", + "rigid": "Raw impact — Rigid", + "soft": "Raw impact — Soft" + } } }, "feature_flag_override": { @@ -6482,7 +6524,7 @@ "onboarding": { "step_progress": "Schritt {{current}} von {{total}}", "title": "Geld aufladen", - "description": "Kaufen Sie mUSD und fangen Sie gleich an, APY zu verdienen.", + "description": "Fund your account and start earning APY.", "add": "Hinzufügen", "step2_title": "Sichern Sie sich Ihre MetaMask Card", "step2_description": "Geben Sie Ihr Geldguthaben aus, während es Gewinne erwirtschaftet, überall dort, wo Mastercard akzeptiert wird.", @@ -6499,7 +6541,8 @@ "earnings": { "title": "Einnahmen", "lifetime": "Lebenslange Einnahmen", - "projected": "Erwartete Einnahmen" + "projected": "Erwartete Einnahmen", + "info_label": "Earnings info" }, "how_it_works": { "title": "Wie es funktioniert", @@ -6511,7 +6554,7 @@ }, "potential_earnings": { "title": "Verdienen Sie an Ihrem Krypto", - "description": "See how your money can grow over time by converting your crypto to mUSD.", + "description": "Beobachten Sie, wie Ihr Geld mit der Zeit wächst, indem Sie Ihre Kryptowährung in mUSD konvertieren.", "convert": "Konvertieren", "no_fee": "Keine MetaMask-Gebühr", "view_all": "Alle anzeigen", @@ -6553,6 +6596,35 @@ "receive_external": "Receive from external wallet", "coming_soon": "Demnächst verfügbar" }, + "more_sheet": { + "title": "Mehr", + "how_it_works": "Wie es funktioniert", + "what_you_get": "Was Sie erhalten", + "contact_support": "Support kontaktieren" + }, + "transfer_sheet": { + "title": "Transfer money", + "between_accounts": "Between accounts", + "perps_account": "Perps account", + "predictions_account": "Predictions account", + "send_external": "Send to external address", + "withdraw_to_bank": "Withdraw to bank" + }, + "apy_tooltip": { + "title": "Annual Percentage Yield (APY)", + "paragraph_1": "Your Money account earns up to {{percentage}}% automatically.", + "paragraph_2": "The rate shown is an estimated Annual Percentage Yield (APY) based on current market conditions. APY is variable and may change due to various factors. The rate is generated by third-party DeFi platforms that deploy funds in blockchain protocols.", + "paragraph_3": "When you link your MetaMask Card, purchases will be deducted from this balance.", + "learn_more": "Mehr erfahren" + }, + "earnings_tooltip": { + "title": "Einnahmen", + "lifetime_heading": "Lebenslange Einnahmen", + "lifetime_body": "The total yield you've earned since opening your Money account.", + "projected_heading": "Erwartete Einnahmen", + "projected_body": "Eine Hochrechnung dessen, was Sie im Laufe eines Jahres basierend auf Ihrem aktuellen Guthaben und Zinssatz verdienen würden.", + "disclaimer": "Projection assumes {{percentage}}% Annual Percentage Yield (APY) remains unchanged for 1 year. APY is variable and may change due to various factors. No guarantee of return." + }, "activity": { "title": "Aktivität", "view_all": "Alle anzeigen", @@ -6917,7 +6989,9 @@ "perps_deposit": "Gelder hinzufügen", "perps_withdraw": "Auszahlen", "predict_deposit": "Prognosegelder hinzufügen", - "predict_withdraw": "Auszahlen" + "predict_withdraw": "Auszahlen", + "money_account_add_money": "Geld aufladen", + "money_account_transfer_money": "Transfer money" }, "sub_title": { "permit": "Diese Website möchte die Genehmigung, Ihre Tokens auszugeben.", @@ -6937,7 +7011,7 @@ "transaction_fee": "Wir swappen Ihre Token gegen USDC auf HyperCore, dem von Perps verwendeten Netzwerk. Swap-Anbieter können eine Gebühr erheben, MetaMask jedoch nicht." }, "predict_deposit": { - "transaction_fee": "Wir swappen Ihre Token gegen USDC.e auf Polygon, dem von Predictions verwendeten Netzwerk. Swap-Anbieter erheben möglicherweise eine Gebühr, MetaMask jedoch nicht." + "transaction_fee": "We'll swap your tokens for pUSD on Polygon, the network used by Predictions. Swap providers may charge a fee, but MetaMask won't." }, "perps_withdraw": { "transaction_fee": "MetaMask swappt den Token für Sie in den gewünschten Token um. Beim Swap in mUSD fallen keine MetaMask-Gebühren an." @@ -7033,7 +7107,8 @@ "custom_amount": { "buy_button": "Krypto kaufen", "buy_predict": "Fügen Sie Ihrer Wallet Gelder hinzu, um Prognosen zu nutzen.", - "buy_perps": "Fügen Sie Ihrer Wallet Gelder hinzu, um Perps zu nutzen." + "buy_perps": "Fügen Sie Ihrer Wallet Gelder hinzu, um Perps zu nutzen.", + "projected_five_year_balance": "Projected 5-year balance:" }, "unlimited": "Unbegrenzt", "all": "Alle", @@ -7113,6 +7188,7 @@ "receive_at": "Empfangen bei", "recipient": "Empfänger", "select_recipient": "Empfänger auswählen", + "select_account": "Konto auswählen", "external_account": "Externes Konto", "error_banner_description": "Diese Handelsroute ist derzeit nicht verfügbar. Versuchen Sie, den Betrag, das Netzwerk oder das Token zu ändern, und wir finden die beste Option.", "stock_token_error_banner_description": "Diese Handelsroute ist momentan nicht verfügbar. Versuchen Sie, den Betrag, das Netzwerk oder den Token zu ändern. Wir finden dann die beste Option für Sie.\n\nBitte beachten Sie: Beim Handel mit Ondo-tokenisierten Aktien können geografische Beschränkungen gelten, z. B. für die USA, die EU, Großbritannien und Brasilien.", @@ -7791,6 +7867,7 @@ "enable_card_button_label": "Karte aktivieren", "enable_assets_button_label": "Assets aktivieren", "spending_limit_warning": "Sie haben Ihr Ausgabenlimit fast erreicht. Aktualisieren Sie Ihr Konto, um Ablehnungen zu vermeiden.", + "spending_limit_available": "verfügbar", "logout": "Abmelden", "contact_support": "Support kontaktieren", "logout_confirmation_title": "Von Karte abmelden?", @@ -7925,7 +8002,12 @@ "withdrawal_success": "Auszahlung erfolgreich abgeschlossen", "withdrawal_failed": "Auszahlung fehlgeschlagen. Bitte versuchen Sie es erneut.", "no_cashback": "Kein Cashback verfügbar", - "loading_error": "Cashback konnte nicht geladen werden. Bitte versuchen Sie es erneut." + "loading_error": "Cashback konnte nicht geladen werden. Bitte versuchen Sie es erneut.", + "funding_required": { + "title": "Set up Linea funding", + "description": "You need at least one approved funding source on Linea before redeeming cashback.", + "confirm_button_label": "Set up funding" + } }, "change_asset": { "title": "Token und Netzwerk ändern", @@ -8030,7 +8112,8 @@ "title": "Zahlungsmethode auswählen", "title_receive": "Token empfangen auswählen", "no_gas": "Kein nativer Saldo für Gas", - "not_supported": "Nicht unterstützt" + "not_supported": "Nicht unterstützt", + "crypto": "Krypto" }, "connection_removed_modal": { "title": "Verbindungen entfernt", @@ -8365,6 +8448,8 @@ "ends_date": "Endet am {{date}}", "ended_date": "Beendet am {{date}}", "pill_up_next": "Demnächst verfügbar", + "up_next": "Als Nächstes", + "notify_me": "Mich benachrichtigen", "pill_active": "Live", "pill_complete": "Abgeschlossen", "enter": "Eingeben", @@ -8378,7 +8463,9 @@ "geo_locked_toast_title": "In Ihrer Region nicht verfügbar", "geo_locked_toast_description": "Diese Kampagne ist in Ihrem Land nicht verfügbar. Schauen Sie später für neue Kampagnen wieder vorbei.", "geo_locked_cta": "Berechtigung prüfen", - "geo_loading": "Region wird überprüft ..." + "geo_loading": "Region wird überprüft ...", + "remind_me_success_toast": "We'll let you know when this campaign starts.", + "remind_me_save_error": "Could not save your reminder. Please try again." }, "campaign_mechanics": { "title": "Mechaniken" @@ -8507,6 +8594,46 @@ "cancel": "Stornieren", "confirm": "Ich verstehe" }, + "perps_trading_campaign": { + "title": "Perps Trading Competition", + "stats_title": "Stats", + "performance_title": "Performance", + "label_rank": "Rank", + "label_your_rank": "Your rank", + "label_pnl": "PnL", + "label_volume": "Volume", + "label_notional_volume": "Notional volume", + "label_margin": "Margin", + "label_margin_deployed": "Margin deployed", + "pending": "Pending", + "qualified": "Qualified", + "open_position_cta": "Open Position", + "leaderboard_title": "Leaderboard", + "leaderboard_total_participants": "{{count}} participants", + "last_updated": "Last updated: {{time}}", + "leaderboard_error_loading": "Failed to load leaderboard", + "leaderboard_error_loading_description": "Something went wrong while loading the leaderboard. Please try again.", + "leaderboard_not_yet_computed": "Leaderboard hasn't been computed yet. Check back soon.", + "leaderboard_powered_by_prefix": "Powered by ", + "leaderboard_hypertracker_brand": "HyperTracker", + "prize_pool_title": "Prize pool", + "prize_pool_current_label": "Current", + "prize_pool_next_label": "Next", + "prize_pool_volume_subtext": "{{current}} of {{target}} volume", + "prize_pool_max_tier_subtext": "{{maxThreshold}}+ volume — all milestones reached", + "prize_pool_max_badge": "Max", + "prize_pool_error_title": "Failed to load prize pool", + "prize_pool_error_description": "There was an error loading the prize pool. Please try again.", + "prize_pool_retry_button": "Retry", + "stats_notional_volume_threshold": "${{amount}} required", + "stats_qualified_title": "You're qualified", + "stats_qualified_description": "Keep trading to improve your PnL and climb the leaderboard before the competition ends.", + "stats_qualify_for_rank_title": "Qualify for this rank", + "stats_qualify_for_rank_description": "Trade {{notionalRemaining}} more in notional volume to become eligible.", + "stats_error_title": "Unable to load stats", + "stats_error_description": "We had a problem loading your stats. Please try again.", + "stats_retry": "Retry" + }, "campaigns_preview": { "title": "Kampagnen", "coming_soon": "Demnächst verfügbar", @@ -8661,7 +8788,9 @@ }, "trending": { "title": "Entdecken", - "trending_tokens": "Trending", + "crypto_movers": "Crypto movers", + "perps_movers": "Perps movers", + "trending_tokens": "Angesagt", "stocks": "Aktien", "price_change": "Preisänderung", "all_networks": "Alle Netzwerke", @@ -8672,6 +8801,17 @@ "1_hour": "1 Stunde", "5_minutes": "5 Minuten", "networks": "Netzwerke", + "ecosystems": "Ecosystems", + "ecosystems_subtitle": "Explore dApps, tokens, and NFTs across chains on MetaMask Portfolio", + "all_sports": "Popular sports", + "basketball": "Basketball", + "football": "Football", + "soccer": "Soccer", + "tennis": "Tennis", + "f1": "F1", + "golf": "Golf", + "all_sports_no_markets": "No open markets in this category yet. Check back soon or pick another sport above.", + "load_more": "Load more", "sort_by": "Sortieren nach", "volume": "Volumen", "market_cap": "Marktkapitalisierung", @@ -8681,8 +8821,17 @@ "search_placeholder": "Tokens, Websites, URLs suchen", "cancel": "Stornieren", "perps": "Perps", + "rwa_perps_section": "Märkte", + "macro_stocks_commodity_perps": "Stocks & commodities", + "macro_pill_stocks": "Aktien", + "macro_pill_commodities": "Rohstoffe", + "rwa_pill_commodities": "Rohstoffe", + "rwa_pill_stocks": "Aktien", + "rwa_pill_forex": "Devisen", + "crypto_perps_section": "Perps", "predictions": "Prognosen", "no_results": "Keine Ergebnisse gefunden", + "popular": "Beliebt", "sites": "Websites", "popular_sites": "Beliebte Websites", "search_sites": "Websites durchsuchen", @@ -8698,6 +8847,14 @@ "empty_search_result_state": { "title": "Keine Tokens gefunden", "description": "Wir konnten dieses Token nicht finden." + }, + "tabs": { + "now": "Jetzt", + "macro": "Makro", + "rwas": "RWAs", + "crypto": "Krypto", + "sports": "Sport", + "dapps": "Websites" } }, "ota_update_modal": { @@ -8715,7 +8872,9 @@ }, "common": { "cancel": "Stornieren", - "continue": "Fortfahren" + "continue": "Fortfahren", + "learn_more": "Mehr erfahren", + "try_again": "Erneut versuchen" }, "connecting": { "title": "Ihr {{device}} wird verbunden ...", @@ -8761,6 +8920,7 @@ "bluetooth_off": "Bitte schalten Sie Bluetooth ein, um eine Verbindung mit Ihrem Gerät herzustellen", "bluetooth_scan_failed": "Scannen nach Geräten fehlgeschlagen. Bitte versuchen Sie es erneut", "bluetooth_connection_failed": "Die Verbindung zu Ihrem Gerät ist fehlgeschlagen. Bitte versuchen Sie es erneut", + "camera_permission_denied": "Camera permission is required to scan QR codes. Please enable it in your device settings", "not_supported": "Dieser Vorgang wird nicht unterstützt", "unknown_error": "Stellen Sie sicher, dass Ihr {{device}} mit der geheimen Wiederherstellungsphrase oder Passphrase für dieses Konto eingerichtet ist" }, @@ -8784,8 +8944,35 @@ "location_permission_denied": "Standortberechtigung erforderlich", "nearby_devices_permission_denied": "Berechtigung für Geräte in der Nähe ist erforderlich", "scan_failed": "Scanvorgang fehlgeschlagen", + "camera_permission_denied": "Camera Permission Required", "something_went_wrong": "Hoppla! Etwas ist schiefgelaufen ..." }, + "qr_scan_errors": { + "non_ur_qr_scanned": { + "pair": { + "title": "Scan wallet sync QR code", + "body": "Go back to your hardware wallet and find the option to share or export your accounts, choose MetaMask, then scan the QR code." + }, + "sign": { + "title": "Scan signature QR code", + "body": "Go back to your hardware wallet and approve the transaction, then scan the QR code it shows." + } + }, + "wrong_ur_type": { + "pair": { + "title": "Wrong QR code", + "body": "Go back to your hardware wallet, choose MetaMask as the wallet type, then generate and scan a new QR code." + }, + "sign": { + "title": "Wrong QR code", + "body": "Go back to your hardware wallet and approve the transaction, then scan the QR code it shows." + } + }, + "ur_decode_error": { + "title": "Couldn't read QR code", + "body": "Make sure the QR code is fully visible and well-lit, and try again. If the issue continues, you may need to update your hardware wallet's firmware." + } + }, "device_selection": { "title": "{{device}} auswählen", "scanning": "Scannen nach Geräten ...", @@ -8803,6 +8990,14 @@ "cash": "Money", "cash_empty_description": "Sie haben noch keine mUSD. Konvertieren Sie Stablecoins in mUSD im Bereich „Geld“ auf der Startseite.", "cash_empty_description_network_filter": "Kein mUSD in diesem Netzwerk. Wechseln Sie das Netzwerk, um Ihre mUSD einzusehen.", + "cash_empty_state": { + "get_started": "Erste Schritte", + "earn_apy": "Earn {{percentage}}% APY" + }, + "cash_filled_state": { + "add": "Hinzufügen", + "apy": "APY von {{percentage}} %" + }, "tokens": "Token", "perpetuals": "Perpetuals", "predictions": "Prognosen", @@ -8818,7 +9013,7 @@ }, "defi": "DeFi", "nfts": "NFTs", - "trending_tokens": "Trending", + "trending_tokens": "Angesagt", "trending_perpetuals": "Trendige Perpetuals", "trending_predictions": "Trendige Prognosen", "import_nfts": "NFTs importieren", @@ -8832,5 +9027,8 @@ "unable_to_load": "{{section}} kann nicht geladen werden", "retry": "Erneut versuchen" } + }, + "sites": { + "popular": "Beliebt" } } diff --git a/locales/languages/el.json b/locales/languages/el.json index 3ae3e54ee2d..8df088c6d71 100644 --- a/locales/languages/el.json +++ b/locales/languages/el.json @@ -634,7 +634,8 @@ "trade": "Συναλλαγές", "settings": "Ρυθμίσεις", "rewards": "Επιβραβεύσεις", - "trending": "Εξερεύνηση" + "trending": "Εξερεύνηση", + "money": "Οικονομικά" }, "drawer": { "send_button": "Αποστολή", @@ -2446,6 +2447,12 @@ } } }, + "payment": { + "change_payment_method": "Change Payment Method", + "add_funds": "Προσθήκη κεφαλαίων", + "add": "Προσθήκη", + "predict_balance": "Predict balance" + }, "order": { "available": "Διαθέσιμο", "cashed_out": "Εξαργυρώθηκε", @@ -3314,6 +3321,8 @@ "invalid_number_range": "Μη έγκυρος αριθμός. Εισαγάγετε έναν αριθμό μεταξύ 1 και %{maxSafeChainId}", "hide_zero_balance_tokens_title": "Απόκρυψη tokens χωρίς διαθέσιμο υπόλοιπο", "hide_zero_balance_tokens_desc": "Αποτρέπει την εμφάνιση των tokens χωρίς υπόλοιπο στην λίστα των tokens σας.", + "haptic_feedback_title": "Haptic feedback", + "haptic_feedback_desc": "Feel subtle vibrations for confirmations, errors, and interactions. Disable to reduce haptic stimuli.", "token_detection_title": "Αυτόματη ανίχνευση tokens", "token_detection_description": "Χρησιμοποιούμε API τρίτων για τον εντοπισμό και την εμφάνιση νέων tokens που αποστέλλονται στο πορτοφόλι σας. Απενεργοποιήστε το εάν δεν θέλετε η εφαρμογή να αντλεί δεδομένα από αυτές τις υπηρεσίες.", "theme_button_text": "Αλλαγή θέματος", @@ -3457,7 +3466,7 @@ "title": "Headless buy", "loading": "Preparing the buy flow…", "no_session": "Headless session is no longer active.", - "cancel": "Cancel" + "cancel": "Ακύρωση" } }, "request_feature": "Ζητήστε μια λειτουργία", @@ -3572,6 +3581,39 @@ "title": "Κάρτα", "reset_onboarding_description": "Επαναφορά της διαδικασίας ενεργοποίησης της Κάρτας για να ξεκινήσει η διαδικασία από την αρχή.", "reset_onboarding_button": "Επαναφορά της διαδικασίας ενεργοποίησης" + }, + "haptics": { + "title": "Haptics", + "description": "Preview haptic feedback on a physical device. This bypasses in-app reduced-haptics and the remote kill switch so you can evaluate each pattern. Simulators often do not play haptics.", + "catalog_impacts_heading": "Catalog impact moments", + "notifications_heading": "Notification patterns", + "selection_heading": "Selection", + "notification_success_button": "Notification — Success", + "notification_error_button": "Notification — Error", + "notification_warning_button": "Notification — Warning", + "selection_button": "Selection", + "raw_impacts_heading": "Raw Expo impact styles", + "raw_impacts_desc": "Direct expo-haptics impact styles for comparison with the mapped catalog moments above.", + "impacts": { + "quick_amount_selection": "Impact — Quick amount selection", + "slider_tick": "Impact — Slider tick", + "edge_gesture_engage": "Impact — Edge gesture engage", + "page_navigation": "Impact — Page navigation", + "slider_grip": "Impact — Slider grip", + "tab_change": "Impact — Tab change", + "primary_cta": "Impact — Primary CTA", + "pull_to_refresh_engage": "Impact — Pull to refresh (engage)", + "pull_to_refresh": "Impact — Pull to refresh (release)", + "chart_crosshair": "Impact — Chart crosshair", + "follow_toggle": "Impact — Follow toggle" + }, + "raw_styles": { + "light": "Raw impact — Light", + "medium": "Raw impact — Medium", + "heavy": "Raw impact — Heavy", + "rigid": "Raw impact — Rigid", + "soft": "Raw impact — Soft" + } } }, "feature_flag_override": { @@ -6482,7 +6524,7 @@ "onboarding": { "step_progress": "Βήμα {{current}} από {{total}}", "title": "Προσθήκη χρημάτων", - "description": "Αγοράστε mUSD και αρχίστε να κερδίζετε από την Ετήσια Ποσοστιαία Απόδοση (APY) σήμερα.", + "description": "Fund your account and start earning APY.", "add": "Προσθήκη", "step2_title": "Αποκτήστε την MetaMask Card", "step2_description": "Ξοδέψτε το υπόλοιπο του λογαριασμού σας όσο αυτό αποδίδει, οπουδήποτε γίνεται δεκτή η Mastercard.", @@ -6499,7 +6541,8 @@ "earnings": { "title": "Κέρδη", "lifetime": "Συνολικά κέρδη", - "projected": "Εκτιμώμενα κέρδη" + "projected": "Εκτιμώμενα κέρδη", + "info_label": "Earnings info" }, "how_it_works": { "title": "Πώς λειτουργεί", @@ -6553,6 +6596,35 @@ "receive_external": "Receive from external wallet", "coming_soon": "Προσεχώς" }, + "more_sheet": { + "title": "Περισσότερα", + "how_it_works": "Πώς λειτουργεί", + "what_you_get": "Τι σας προσφέρει", + "contact_support": "Επικοινωνία με την υποστήριξη" + }, + "transfer_sheet": { + "title": "Transfer money", + "between_accounts": "Between accounts", + "perps_account": "Perps account", + "predictions_account": "Predictions account", + "send_external": "Send to external address", + "withdraw_to_bank": "Withdraw to bank" + }, + "apy_tooltip": { + "title": "Annual Percentage Yield (APY)", + "paragraph_1": "Your Money account earns up to {{percentage}}% automatically.", + "paragraph_2": "The rate shown is an estimated Annual Percentage Yield (APY) based on current market conditions. APY is variable and may change due to various factors. The rate is generated by third-party DeFi platforms that deploy funds in blockchain protocols.", + "paragraph_3": "When you link your MetaMask Card, purchases will be deducted from this balance.", + "learn_more": "Μάθετε περισσότερα" + }, + "earnings_tooltip": { + "title": "Κέρδη", + "lifetime_heading": "Συνολικά κέρδη", + "lifetime_body": "The total yield you've earned since opening your Money account.", + "projected_heading": "Εκτιμώμενα κέρδη", + "projected_body": "Μια εκτιμηση των ετήσιων κερδών σας, με βάση το τρέχον υπόλοιπο και τον ρυθμό απόδοσης.", + "disclaimer": "Projection assumes {{percentage}}% Annual Percentage Yield (APY) remains unchanged for 1 year. APY is variable and may change due to various factors. No guarantee of return." + }, "activity": { "title": "Δραστηριότητα", "view_all": "Προβολή όλων", @@ -6917,7 +6989,9 @@ "perps_deposit": "Προσθήκη κεφαλαίων", "perps_withdraw": "Ανάληψη", "predict_deposit": "Προσθήκη κεφαλαίων για Προβλέψεις", - "predict_withdraw": "Ανάληψη" + "predict_withdraw": "Ανάληψη", + "money_account_add_money": "Προσθήκη χρημάτων", + "money_account_transfer_money": "Transfer money" }, "sub_title": { "permit": "Αυτός ο ιστότοπος θέλει άδεια για να δαπανήσει τα tokens σας.", @@ -6937,7 +7011,7 @@ "transaction_fee": "Θα ανταλλάξουμε τα tokens σας με USDC στο HyperCore, το δίκτυο που χρησιμοποιείται από τα Perps. Οι πάροχοι ανταλλαγής ενδέχεται να χρεώσουν προμήθεια, αλλά το MetaMask δεν χρεώνει." }, "predict_deposit": { - "transaction_fee": "Θα ανταλλάξουμε τα tokens σας με USDC.e στο δίκτυο Polygon, που χρησιμοποιείται για τις Προβλέψεις. Οι πάροχοι ανταλλαγών ενδέχεται να χρεώσουν προμήθεια, αλλά το MetaMask δεν χρεώνει." + "transaction_fee": "We'll swap your tokens for pUSD on Polygon, the network used by Predictions. Swap providers may charge a fee, but MetaMask won't." }, "perps_withdraw": { "transaction_fee": "Το MetaMask θα κάνει ανταλλαγή στο token που θέλετε. Δεν υπάρχει χρέωση στο MetaMask όταν κάνετε ανταλλαγή σε mUSD." @@ -7033,7 +7107,8 @@ "custom_amount": { "buy_button": "Αγορά κρυπτονομισμάτων", "buy_predict": "Προσθέστε χρήματα στο πορτοφόλι σας για να χρησιμοποιήσετε τις Προβλέψεις.", - "buy_perps": "Προσθέστε χρήματα στο πορτοφόλι σας για να χρησιμοποιήσετε τα Perps." + "buy_perps": "Προσθέστε χρήματα στο πορτοφόλι σας για να χρησιμοποιήσετε τα Perps.", + "projected_five_year_balance": "Projected 5-year balance:" }, "unlimited": "Απεριόριστα", "all": "Όλα", @@ -7113,6 +7188,7 @@ "receive_at": "Θα λάβετε στις", "recipient": "Αποδέκτης", "select_recipient": "Επιλέξτε παραλήπτη", + "select_account": "Επιλέξτε λογαριασμό", "external_account": "Εξωτερικός λογαριασμός", "error_banner_description": "Αυτή η διαδρομή συναλλαγών δεν είναι διαθέσιμη προς το παρόν. Δοκιμάστε να αλλάξετε το ποσό, το δίκτυο ή το token και θα βρούμε την καλύτερη επιλογή.", "stock_token_error_banner_description": "Αυτή η διαδρομή συναλλαγών δεν είναι διαθέσιμη αυτήν τη στιγμή. Δοκιμάστε να αλλάξετε το ποσό, το δίκτυο ή το token και θα βρούμε την καλύτερη επιλογή.\n\nΛάβετε υπόψη ότι εάν προσπαθείτε να κάνετε συναλλαγές με μετοχές Ondo Tokenised, ενδέχεται να έχετε γεωγραφικούς περιορισμούς, π.χ. μέσω ΗΠΑ, ΕΕ, Ηνωμένου Βασιλείου και Βραζιλίας.", @@ -7791,6 +7867,7 @@ "enable_card_button_label": "Ενεργοποίηση κάρτας", "enable_assets_button_label": "Ενεργοποιήστε τα περιουσιακά στοιχεία", "spending_limit_warning": "Έχετε σχεδόν φτάσει το όριο δαπανών σας. Ενημερώστε για να αποφύγετε απορρίψεις.", + "spending_limit_available": "διαθέσιμο", "logout": "Αποσύνδεση", "contact_support": "Επικοινωνία με την υποστήριξη", "logout_confirmation_title": "Αποσύνδεση από την κάρτα;", @@ -7925,7 +8002,12 @@ "withdrawal_success": "Η ανάληψη ολοκληρώθηκε με επιτυχία", "withdrawal_failed": "Η ανάληψη απέτυχε. Προσπαθήστε ξανά.", "no_cashback": "Δεν υπάρχει διαθέσιμη επιστροφή χρημάτων", - "loading_error": "Αποτυχία φόρτωσης της επιστροφής χρημάτων. Προσπαθήστε ξανά." + "loading_error": "Αποτυχία φόρτωσης της επιστροφής χρημάτων. Προσπαθήστε ξανά.", + "funding_required": { + "title": "Set up Linea funding", + "description": "You need at least one approved funding source on Linea before redeeming cashback.", + "confirm_button_label": "Set up funding" + } }, "change_asset": { "title": "Αλλαγή token και δικτύου", @@ -8030,7 +8112,8 @@ "title": "Επιλέξτε μέθοδο πληρωμής", "title_receive": "Επιλέξτε το token που θα λάβετε", "no_gas": "Δεν υπάρχει διαθέσιμο υπόλοιπο για τα τέλη συναλλαγών", - "not_supported": "Δεν υποστηρίζεται" + "not_supported": "Δεν υποστηρίζεται", + "crypto": "Κρύπτο" }, "connection_removed_modal": { "title": "Οι συνδέσεις αφαιρέθηκαν", @@ -8365,6 +8448,8 @@ "ends_date": "Λήγει {{date}}", "ended_date": "Έληξε στις {{date}}", "pill_up_next": "Προσεχώς", + "up_next": "Επόμενο", + "notify_me": "Ειδοποιήστε με", "pill_active": "Ζωντανά", "pill_complete": "Ολοκληρώθηκε", "enter": "Είσοδος", @@ -8378,7 +8463,9 @@ "geo_locked_toast_title": "Δεν είναι διαθέσιμη στην περιοχή σας", "geo_locked_toast_description": "Αυτή η καμπάνια δεν είναι διαθέσιμη στην περιοχή σας. Επιστρέψτε αργότερα για νέες καμπάνιες.", "geo_locked_cta": "Έλεγχος επιλεξιμότητας", - "geo_loading": "Έλεγχος περιοχής…" + "geo_loading": "Έλεγχος περιοχής…", + "remind_me_success_toast": "We'll let you know when this campaign starts.", + "remind_me_save_error": "Could not save your reminder. Please try again." }, "campaign_mechanics": { "title": "Κανόνες" @@ -8507,6 +8594,46 @@ "cancel": "Ακύρωση", "confirm": "Κατανοώ" }, + "perps_trading_campaign": { + "title": "Perps Trading Competition", + "stats_title": "Stats", + "performance_title": "Performance", + "label_rank": "Rank", + "label_your_rank": "Your rank", + "label_pnl": "PnL", + "label_volume": "Volume", + "label_notional_volume": "Notional volume", + "label_margin": "Margin", + "label_margin_deployed": "Margin deployed", + "pending": "Pending", + "qualified": "Qualified", + "open_position_cta": "Open Position", + "leaderboard_title": "Leaderboard", + "leaderboard_total_participants": "{{count}} participants", + "last_updated": "Last updated: {{time}}", + "leaderboard_error_loading": "Failed to load leaderboard", + "leaderboard_error_loading_description": "Something went wrong while loading the leaderboard. Please try again.", + "leaderboard_not_yet_computed": "Leaderboard hasn't been computed yet. Check back soon.", + "leaderboard_powered_by_prefix": "Powered by ", + "leaderboard_hypertracker_brand": "HyperTracker", + "prize_pool_title": "Prize pool", + "prize_pool_current_label": "Current", + "prize_pool_next_label": "Next", + "prize_pool_volume_subtext": "{{current}} of {{target}} volume", + "prize_pool_max_tier_subtext": "{{maxThreshold}}+ volume — all milestones reached", + "prize_pool_max_badge": "Max", + "prize_pool_error_title": "Failed to load prize pool", + "prize_pool_error_description": "There was an error loading the prize pool. Please try again.", + "prize_pool_retry_button": "Retry", + "stats_notional_volume_threshold": "${{amount}} required", + "stats_qualified_title": "You're qualified", + "stats_qualified_description": "Keep trading to improve your PnL and climb the leaderboard before the competition ends.", + "stats_qualify_for_rank_title": "Qualify for this rank", + "stats_qualify_for_rank_description": "Trade {{notionalRemaining}} more in notional volume to become eligible.", + "stats_error_title": "Unable to load stats", + "stats_error_description": "We had a problem loading your stats. Please try again.", + "stats_retry": "Retry" + }, "campaigns_preview": { "title": "Καμπάνιες", "coming_soon": "Προσεχώς", @@ -8661,7 +8788,9 @@ }, "trending": { "title": "Εξερεύνηση", - "trending_tokens": "Τάσεις", + "crypto_movers": "Crypto movers", + "perps_movers": "Perps movers", + "trending_tokens": "Δημοφιλή", "stocks": "Μετοχές", "price_change": "Αλλαγή τιμής", "all_networks": "Όλα τα δίκτυα", @@ -8672,6 +8801,17 @@ "1_hour": "1 ώρα", "5_minutes": "5 λεπτά", "networks": "Δίκτυα", + "ecosystems": "Ecosystems", + "ecosystems_subtitle": "Explore dApps, tokens, and NFTs across chains on MetaMask Portfolio", + "all_sports": "Popular sports", + "basketball": "Basketball", + "football": "Football", + "soccer": "Soccer", + "tennis": "Tennis", + "f1": "F1", + "golf": "Golf", + "all_sports_no_markets": "No open markets in this category yet. Check back soon or pick another sport above.", + "load_more": "Load more", "sort_by": "Ταξινόμηση κατά", "volume": "Όγκος", "market_cap": "Κεφαλαιοποίηση αγοράς", @@ -8681,8 +8821,17 @@ "search_placeholder": "Αναζήτηση token, ιστότοπων, διευθύνσεων URL", "cancel": "Άκυρο", "perps": "Συμβ.αορ.", + "rwa_perps_section": "Αγορές", + "macro_stocks_commodity_perps": "Stocks & commodities", + "macro_pill_stocks": "Μετοχές", + "macro_pill_commodities": "Εμπορεύματα", + "rwa_pill_commodities": "Εμπορεύματα", + "rwa_pill_stocks": "Μετοχές", + "rwa_pill_forex": "Forex", + "crypto_perps_section": "Συμβ.αορ.", "predictions": "Προβλέψεις", "no_results": "Δεν βρέθηκαν αποτελέσματα", + "popular": "Δημοφιλή", "sites": "Ιστότοποι", "popular_sites": "Δημοφιλείς ιστότοποι", "search_sites": "Αναζήτηση ιστότοπων", @@ -8698,6 +8847,14 @@ "empty_search_result_state": { "title": "Δεν βρέθηκαν token", "description": "Δεν μπορέσαμε να βρούμε αυτό το token" + }, + "tabs": { + "now": "Τώρα", + "macro": "Μακροοικονομικά", + "rwas": "RWAs", + "crypto": "Κρύπτο", + "sports": "Αθλητικά", + "dapps": "Ιστότοποι" } }, "ota_update_modal": { @@ -8715,7 +8872,9 @@ }, "common": { "cancel": "Άκυρο", - "continue": "Συνεχίστε" + "continue": "Συνεχίστε", + "learn_more": "Μάθετε περισσότερα", + "try_again": "Προσπαθήστε ξανά" }, "connecting": { "title": "Σύνδεση με το {{device}}...", @@ -8761,6 +8920,7 @@ "bluetooth_off": "Ενεργοποιήστε το Bluetooth για να συνδεθεί με τη συσκευή σας", "bluetooth_scan_failed": "Δεν ήταν δυνατή η σάρωση συσκευών. Προσπαθήστε ξανά", "bluetooth_connection_failed": "Η σύνδεση με τη συσκευή σας απέτυχε. Προσπαθήστε ξανά", + "camera_permission_denied": "Camera permission is required to scan QR codes. Please enable it in your device settings", "not_supported": "Αυτή η λειτουργία δεν υποστηρίζεται", "unknown_error": "Βεβαιωθείτε ότι το {{device}} σας έχει ρυθμιστεί με τη Μυστική Φράση Ανάκτησης ή τη φράση πρόσβασης για αυτόν τον λογαριασμό" }, @@ -8784,8 +8944,35 @@ "location_permission_denied": "Απαιτείται άδεια τοποθεσίας", "nearby_devices_permission_denied": "Απαιτείται άδεια πρόσβασης για κοντινές συσκευές", "scan_failed": "Η σάρωση απέτυχε", + "camera_permission_denied": "Camera Permission Required", "something_went_wrong": "Κάτι πήγε στραβά" }, + "qr_scan_errors": { + "non_ur_qr_scanned": { + "pair": { + "title": "Scan wallet sync QR code", + "body": "Go back to your hardware wallet and find the option to share or export your accounts, choose MetaMask, then scan the QR code." + }, + "sign": { + "title": "Scan signature QR code", + "body": "Go back to your hardware wallet and approve the transaction, then scan the QR code it shows." + } + }, + "wrong_ur_type": { + "pair": { + "title": "Wrong QR code", + "body": "Go back to your hardware wallet, choose MetaMask as the wallet type, then generate and scan a new QR code." + }, + "sign": { + "title": "Wrong QR code", + "body": "Go back to your hardware wallet and approve the transaction, then scan the QR code it shows." + } + }, + "ur_decode_error": { + "title": "Couldn't read QR code", + "body": "Make sure the QR code is fully visible and well-lit, and try again. If the issue continues, you may need to update your hardware wallet's firmware." + } + }, "device_selection": { "title": "Επιλέξτε το {{device}}", "scanning": "Σάρωση για συσκευές...", @@ -8803,6 +8990,14 @@ "cash": "Οικονομικά", "cash_empty_description": "Δεν έχετε ακόμη mUSD. Μετατρέψτε stablecoins σε mUSD από την ενότητα Οικονομικά στην αρχική σελίδα.", "cash_empty_description_network_filter": "Δεν έχετε mUSD σε αυτό το δίκτυο. Αλλάξτε δίκτυο για να δείτε τα mUSD σας.", + "cash_empty_state": { + "get_started": "Ξεκινήστε", + "earn_apy": "Earn {{percentage}}% APY" + }, + "cash_filled_state": { + "add": "Προσθήκη", + "apy": "{{percentage}}% APY" + }, "tokens": "Token", "perpetuals": "Συμβόλαια αορίστου διάρκειας", "predictions": "Προβλέψεις", @@ -8818,7 +9013,7 @@ }, "defi": "DeFi", "nfts": "NFT", - "trending_tokens": "Τάσεις", + "trending_tokens": "Δημοφιλή", "trending_perpetuals": "Δημοφιλή perpetuals", "trending_predictions": "Δημοφιλείς προβλέψεις", "import_nfts": "Εισαγωγή NFT", @@ -8832,5 +9027,8 @@ "unable_to_load": "Δεν είναι δυνατή η φόρτωση του {{section}}", "retry": "Επανάληψη" } + }, + "sites": { + "popular": "Δημοφιλή" } } diff --git a/locales/languages/en.json b/locales/languages/en.json index 62fc87108e4..aeb7ba9d85e 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -8129,6 +8129,15 @@ "title": "Opt-in failed", "description": "Check your connection and try again." }, + "notifications_nudge": { + "title": "Don't miss out", + "description": "Enable notifications to stay informed on campaigns.", + "turn_on_button": "Turn on", + "loading": "Enabling notifications...", + "loading_description": "This may take a moment.", + "enable_error": "Failed to enable notifications", + "success": "Notifications enabled" + }, "version_guard": { "title": "Update required", "description": "A newer version of MetaMask is required to use Rewards. Please update to continue.", @@ -8999,9 +9008,15 @@ "apy": "{{percentage}}% APY" }, "tokens": "Tokens", + "perps": "Perps", "perpetuals": "Perpetuals", "predictions": "Predictions", "whats_happening": "What's happening", + "whats_happening_impact": { + "bullish": "Bullish", + "bearish": "Bearish", + "neutral": "Neutral" + }, "top_traders": "Top Traders", "whats_happening_categories": { "geopolitical": "Geopolitical", diff --git a/locales/languages/es.json b/locales/languages/es.json index f421ded6f78..f228b7929ee 100644 --- a/locales/languages/es.json +++ b/locales/languages/es.json @@ -634,7 +634,8 @@ "trade": "Operar", "settings": "Configuración", "rewards": "Recompensas", - "trending": "Explorar" + "trending": "Explorar", + "money": "Money" }, "drawer": { "send_button": "Enviar", @@ -2446,6 +2447,12 @@ } } }, + "payment": { + "change_payment_method": "Change Payment Method", + "add_funds": "Agregar fondos", + "add": "Agregar", + "predict_balance": "Predict balance" + }, "order": { "available": "Disponible", "cashed_out": "Cobrado", @@ -3314,6 +3321,8 @@ "invalid_number_range": "Número no válido. Escriba un número entre 1 y %{maxSafeChainId}.", "hide_zero_balance_tokens_title": "Ocultar tokens sin saldo", "hide_zero_balance_tokens_desc": "Al ocultar los tokens sin saldo, estos no aparecerán en su lista de tokens.", + "haptic_feedback_title": "Haptic feedback", + "haptic_feedback_desc": "Feel subtle vibrations for confirmations, errors, and interactions. Disable to reduce haptic stimuli.", "token_detection_title": "Detectar tókenes automáticamente", "token_detection_description": "Usamos API de terceros para detectar y mostrar nuevos tokens enviados a su monedero. Desactívelo si no desea que la aplicación extraiga datos de esos servicios.", "theme_button_text": "Cambiar tema", @@ -3457,7 +3466,7 @@ "title": "Headless buy", "loading": "Preparing the buy flow…", "no_session": "Headless session is no longer active.", - "cancel": "Cancel" + "cancel": "Cancelar" } }, "request_feature": "Solicitar una función", @@ -3572,6 +3581,39 @@ "title": "Tarjeta", "reset_onboarding_description": "Restablecer el estado de incorporación de la tarjeta para iniciar el proceso de incorporación desde el principio.", "reset_onboarding_button": "Restablecer estado de incorporación" + }, + "haptics": { + "title": "Haptics", + "description": "Preview haptic feedback on a physical device. This bypasses in-app reduced-haptics and the remote kill switch so you can evaluate each pattern. Simulators often do not play haptics.", + "catalog_impacts_heading": "Catalog impact moments", + "notifications_heading": "Notification patterns", + "selection_heading": "Selection", + "notification_success_button": "Notification — Success", + "notification_error_button": "Notification — Error", + "notification_warning_button": "Notification — Warning", + "selection_button": "Selection", + "raw_impacts_heading": "Raw Expo impact styles", + "raw_impacts_desc": "Direct expo-haptics impact styles for comparison with the mapped catalog moments above.", + "impacts": { + "quick_amount_selection": "Impact — Quick amount selection", + "slider_tick": "Impact — Slider tick", + "edge_gesture_engage": "Impact — Edge gesture engage", + "page_navigation": "Impact — Page navigation", + "slider_grip": "Impact — Slider grip", + "tab_change": "Impact — Tab change", + "primary_cta": "Impact — Primary CTA", + "pull_to_refresh_engage": "Impact — Pull to refresh (engage)", + "pull_to_refresh": "Impact — Pull to refresh (release)", + "chart_crosshair": "Impact — Chart crosshair", + "follow_toggle": "Impact — Follow toggle" + }, + "raw_styles": { + "light": "Raw impact — Light", + "medium": "Raw impact — Medium", + "heavy": "Raw impact — Heavy", + "rigid": "Raw impact — Rigid", + "soft": "Raw impact — Soft" + } } }, "feature_flag_override": { @@ -6482,7 +6524,7 @@ "onboarding": { "step_progress": "Paso {{current}} de {{total}}", "title": "Agregar dinero", - "description": "Compra mUSD y comienza a ganar APY hoy mismo.", + "description": "Fund your account and start earning APY.", "add": "Agregar", "step2_title": "Obtén tu tarjeta MetaMask", "step2_description": "Gasta tu saldo de Money mientras genera ganancias, en cualquier lugar donde se acepte Mastercard.", @@ -6499,7 +6541,8 @@ "earnings": { "title": "Ganancias", "lifetime": "Ganancias totales", - "projected": "Ganancias proyectadas" + "projected": "Ganancias proyectadas", + "info_label": "Earnings info" }, "how_it_works": { "title": "Cómo funciona", @@ -6553,6 +6596,35 @@ "receive_external": "Receive from external wallet", "coming_soon": "Próximamente" }, + "more_sheet": { + "title": "Más", + "how_it_works": "Cómo funciona", + "what_you_get": "Lo que obtienes", + "contact_support": "Contactar a soporte" + }, + "transfer_sheet": { + "title": "Transfer money", + "between_accounts": "Between accounts", + "perps_account": "Perps account", + "predictions_account": "Predictions account", + "send_external": "Send to external address", + "withdraw_to_bank": "Withdraw to bank" + }, + "apy_tooltip": { + "title": "Annual Percentage Yield (APY)", + "paragraph_1": "Your Money account earns up to {{percentage}}% automatically.", + "paragraph_2": "The rate shown is an estimated Annual Percentage Yield (APY) based on current market conditions. APY is variable and may change due to various factors. The rate is generated by third-party DeFi platforms that deploy funds in blockchain protocols.", + "paragraph_3": "When you link your MetaMask Card, purchases will be deducted from this balance.", + "learn_more": "Conozca más" + }, + "earnings_tooltip": { + "title": "Ganancias", + "lifetime_heading": "Ganancias totales", + "lifetime_body": "The total yield you've earned since opening your Money account.", + "projected_heading": "Ganancias proyectadas", + "projected_body": "Una proyección de lo que ganarías en un año según tu saldo y tasa actuales.", + "disclaimer": "Projection assumes {{percentage}}% Annual Percentage Yield (APY) remains unchanged for 1 year. APY is variable and may change due to various factors. No guarantee of return." + }, "activity": { "title": "Actividad", "view_all": "Ver todo", @@ -6917,7 +6989,9 @@ "perps_deposit": "Agregar fondos", "perps_withdraw": "Retirar", "predict_deposit": "Añadir fondos de Predicciones", - "predict_withdraw": "Retirar" + "predict_withdraw": "Retirar", + "money_account_add_money": "Agregar dinero", + "money_account_transfer_money": "Transfer money" }, "sub_title": { "permit": "Este sitio necesita permiso para gastar sus tokens.", @@ -6937,7 +7011,7 @@ "transaction_fee": "Canjearemos tus tokens por USDC en HyperCore, la red que usan los contratos perpetuos. Los proveedores de canje pueden cobrar una comisión, pero MetaMask no." }, "predict_deposit": { - "transaction_fee": "Canjearemos tus tokens por USDC en Polygon, la red que usa Predicciones. Los proveedores de canje pueden cobrar una comisión, pero MetaMask no." + "transaction_fee": "We'll swap your tokens for pUSD on Polygon, the network used by Predictions. Swap providers may charge a fee, but MetaMask won't." }, "perps_withdraw": { "transaction_fee": "MetaMask realizará el canje por el token que desees. No se aplica ninguna tarifa de MetaMask al canjear a mUSD." @@ -7033,7 +7107,8 @@ "custom_amount": { "buy_button": "Comprar criptomonedas", "buy_predict": "Añade fondos a tu billetera para utilizar Predicciones.", - "buy_perps": "Añade fondos a tu billetera para usar Perps." + "buy_perps": "Añade fondos a tu billetera para usar Perps.", + "projected_five_year_balance": "Projected 5-year balance:" }, "unlimited": "Ilimitado", "all": "Todo", @@ -7113,6 +7188,7 @@ "receive_at": "Recibir en", "recipient": "Destinatario", "select_recipient": "Seleccionar destinatario", + "select_account": "Selecciona una cuenta", "external_account": "Cuenta externa", "error_banner_description": "Esta ruta de operación no está disponible en este momento. Intente cambiar el monto, la red o el token y encontraremos la mejor opción.", "stock_token_error_banner_description": "Esta ruta de intercambio no está disponible en este momento. Intenta cambiar la cantidad, la red o el token y encontraremos la mejor opción.\n\nTen en cuenta que si intentas operar con acciones tokenizadas de Ondo, podrías estar sujeto a restricciones geográficas, por ejemplo, en EE. UU., la UE, el Reino Unido y Brasil.", @@ -7791,6 +7867,7 @@ "enable_card_button_label": "Habilitar tarjeta", "enable_assets_button_label": "Habilitar activos", "spending_limit_warning": "Estás cerca de tu límite de gasto. Actualiza tu cuenta para evitar rechazos.", + "spending_limit_available": "disponible", "logout": "Cerrar sesión", "contact_support": "Contactar a soporte", "logout_confirmation_title": "¿Cerrar sesión en Card?", @@ -7925,7 +8002,12 @@ "withdrawal_success": "Retiro finalizado correctamente", "withdrawal_failed": "Retiro fallido. Inténtalo de nuevo.", "no_cashback": "No hay cashback disponible", - "loading_error": "Error al cargar el cashback. Inténtalo de nuevo." + "loading_error": "Error al cargar el cashback. Inténtalo de nuevo.", + "funding_required": { + "title": "Set up Linea funding", + "description": "You need at least one approved funding source on Linea before redeeming cashback.", + "confirm_button_label": "Set up funding" + } }, "change_asset": { "title": "Cambiar token y red", @@ -8030,7 +8112,8 @@ "title": "Seleccionar método de pago", "title_receive": "Seleccionar el token a recibir", "no_gas": "No hay saldo nativo para gas", - "not_supported": "No compatible" + "not_supported": "No compatible", + "crypto": "Cripto" }, "connection_removed_modal": { "title": "Conexiones eliminadas", @@ -8365,6 +8448,8 @@ "ends_date": "Termina el {{date}}", "ended_date": "Finalizó el {{date}}", "pill_up_next": "Próximamente", + "up_next": "A continuación", + "notify_me": "Notificarme", "pill_active": "En vivo", "pill_complete": "Completado", "enter": "Participar", @@ -8378,7 +8463,9 @@ "geo_locked_toast_title": "No está disponible en tu región", "geo_locked_toast_description": "Esta campaña no está disponible en tu ubicación. Vuelve más tarde para ver nuevas campañas.", "geo_locked_cta": "Verificar elegibilidad", - "geo_loading": "Comprobando región..." + "geo_loading": "Comprobando región...", + "remind_me_success_toast": "We'll let you know when this campaign starts.", + "remind_me_save_error": "Could not save your reminder. Please try again." }, "campaign_mechanics": { "title": "Mecánica" @@ -8507,6 +8594,46 @@ "cancel": "Cancelar", "confirm": "Comprendo" }, + "perps_trading_campaign": { + "title": "Perps Trading Competition", + "stats_title": "Stats", + "performance_title": "Performance", + "label_rank": "Rank", + "label_your_rank": "Your rank", + "label_pnl": "PnL", + "label_volume": "Volume", + "label_notional_volume": "Notional volume", + "label_margin": "Margin", + "label_margin_deployed": "Margin deployed", + "pending": "Pending", + "qualified": "Qualified", + "open_position_cta": "Open Position", + "leaderboard_title": "Leaderboard", + "leaderboard_total_participants": "{{count}} participants", + "last_updated": "Last updated: {{time}}", + "leaderboard_error_loading": "Failed to load leaderboard", + "leaderboard_error_loading_description": "Something went wrong while loading the leaderboard. Please try again.", + "leaderboard_not_yet_computed": "Leaderboard hasn't been computed yet. Check back soon.", + "leaderboard_powered_by_prefix": "Powered by ", + "leaderboard_hypertracker_brand": "HyperTracker", + "prize_pool_title": "Prize pool", + "prize_pool_current_label": "Current", + "prize_pool_next_label": "Next", + "prize_pool_volume_subtext": "{{current}} of {{target}} volume", + "prize_pool_max_tier_subtext": "{{maxThreshold}}+ volume — all milestones reached", + "prize_pool_max_badge": "Max", + "prize_pool_error_title": "Failed to load prize pool", + "prize_pool_error_description": "There was an error loading the prize pool. Please try again.", + "prize_pool_retry_button": "Retry", + "stats_notional_volume_threshold": "${{amount}} required", + "stats_qualified_title": "You're qualified", + "stats_qualified_description": "Keep trading to improve your PnL and climb the leaderboard before the competition ends.", + "stats_qualify_for_rank_title": "Qualify for this rank", + "stats_qualify_for_rank_description": "Trade {{notionalRemaining}} more in notional volume to become eligible.", + "stats_error_title": "Unable to load stats", + "stats_error_description": "We had a problem loading your stats. Please try again.", + "stats_retry": "Retry" + }, "campaigns_preview": { "title": "Campañas", "coming_soon": "Próximamente", @@ -8661,6 +8788,8 @@ }, "trending": { "title": "Explorar", + "crypto_movers": "Crypto movers", + "perps_movers": "Perps movers", "trending_tokens": "Tendencias", "stocks": "Acciones", "price_change": "Cambio de precio", @@ -8672,6 +8801,17 @@ "1_hour": "1 hora", "5_minutes": "5 minutos", "networks": "Redes", + "ecosystems": "Ecosystems", + "ecosystems_subtitle": "Explore dApps, tokens, and NFTs across chains on MetaMask Portfolio", + "all_sports": "Popular sports", + "basketball": "Basketball", + "football": "Football", + "soccer": "Soccer", + "tennis": "Tennis", + "f1": "F1", + "golf": "Golf", + "all_sports_no_markets": "No open markets in this category yet. Check back soon or pick another sport above.", + "load_more": "Load more", "sort_by": "Ordenar por", "volume": "Volumen", "market_cap": "Capitalización bursátil", @@ -8681,8 +8821,17 @@ "search_placeholder": "Buscar tokens, sitios, URL", "cancel": "Cancelar", "perps": "Perps", + "rwa_perps_section": "Mercados", + "macro_stocks_commodity_perps": "Stocks & commodities", + "macro_pill_stocks": "Acciones", + "macro_pill_commodities": "Materias primas", + "rwa_pill_commodities": "Materias primas", + "rwa_pill_stocks": "Acciones", + "rwa_pill_forex": "Forex", + "crypto_perps_section": "Perps", "predictions": "Predicciones", "no_results": "No se encontraron resultados", + "popular": "Populares", "sites": "Sitios", "popular_sites": "Sitios populares", "search_sites": "Sitios de búsqueda", @@ -8698,6 +8847,14 @@ "empty_search_result_state": { "title": "No se encontraron tokens", "description": "No pudimos encontrar este token" + }, + "tabs": { + "now": "Ahora", + "macro": "Macro", + "rwas": "RWAs", + "crypto": "Cripto", + "sports": "Deportes", + "dapps": "Sitios" } }, "ota_update_modal": { @@ -8715,7 +8872,9 @@ }, "common": { "cancel": "Cancelar", - "continue": "Continuar" + "continue": "Continuar", + "learn_more": "Conozca más", + "try_again": "Inténtalo de nuevo" }, "connecting": { "title": "Conectando tu {{device}}...", @@ -8761,6 +8920,7 @@ "bluetooth_off": "Activa el Bluetooth para conectar tu dispositivo", "bluetooth_scan_failed": "Error al buscar dispositivos. Inténtalo de nuevo", "bluetooth_connection_failed": "Falló la conexión con tu dispositivo. Inténtalo de nuevo", + "camera_permission_denied": "Camera permission is required to scan QR codes. Please enable it in your device settings", "not_supported": "No se admite esta operación", "unknown_error": "Asegúrate de que tu {{device}} esté configurado con la frase secreta de recuperación o la frase de contraseña de esta cuenta" }, @@ -8784,8 +8944,35 @@ "location_permission_denied": "Se requiere permiso de ubicación", "nearby_devices_permission_denied": "Se requiere permiso para dispositivos cercanos", "scan_failed": "Error al buscar", + "camera_permission_denied": "Camera Permission Required", "something_went_wrong": "Algo salió mal" }, + "qr_scan_errors": { + "non_ur_qr_scanned": { + "pair": { + "title": "Scan wallet sync QR code", + "body": "Go back to your hardware wallet and find the option to share or export your accounts, choose MetaMask, then scan the QR code." + }, + "sign": { + "title": "Scan signature QR code", + "body": "Go back to your hardware wallet and approve the transaction, then scan the QR code it shows." + } + }, + "wrong_ur_type": { + "pair": { + "title": "Wrong QR code", + "body": "Go back to your hardware wallet, choose MetaMask as the wallet type, then generate and scan a new QR code." + }, + "sign": { + "title": "Wrong QR code", + "body": "Go back to your hardware wallet and approve the transaction, then scan the QR code it shows." + } + }, + "ur_decode_error": { + "title": "Couldn't read QR code", + "body": "Make sure the QR code is fully visible and well-lit, and try again. If the issue continues, you may need to update your hardware wallet's firmware." + } + }, "device_selection": { "title": "Seleccionar {{device}}", "scanning": "Buscando dispositivos...", @@ -8803,6 +8990,14 @@ "cash": "Money", "cash_empty_description": "Aún no tienes mUSD. Convierte monedas estables a mUSD desde la sección Finanzas en la página de inicio.", "cash_empty_description_network_filter": "No hay mUSD en esta red. Cambia de red para ver tus mUSD.", + "cash_empty_state": { + "get_started": "Comenzar", + "earn_apy": "Earn {{percentage}}% APY" + }, + "cash_filled_state": { + "add": "Agregar", + "apy": "{{percentage}} % de APY" + }, "tokens": "Tokens", "perpetuals": "Contratos perpetuos", "predictions": "Predicciones", @@ -8832,5 +9027,8 @@ "unable_to_load": "No se pudo cargar {{section}}", "retry": "Reintentar" } + }, + "sites": { + "popular": "Populares" } } diff --git a/locales/languages/fr.json b/locales/languages/fr.json index 798963600a4..5d699bb6f69 100644 --- a/locales/languages/fr.json +++ b/locales/languages/fr.json @@ -634,7 +634,8 @@ "trade": "Trader", "settings": "Paramètres", "rewards": "Récompenses", - "trending": "Explorer" + "trending": "Explorer", + "money": "Money" }, "drawer": { "send_button": "Envoyer", @@ -2446,6 +2447,12 @@ } } }, + "payment": { + "change_payment_method": "Change Payment Method", + "add_funds": "Ajouter des fonds", + "add": "Ajouter", + "predict_balance": "Predict balance" + }, "order": { "available": "Disponible ", "cashed_out": "Encaissé", @@ -3314,6 +3321,8 @@ "invalid_number_range": "Numéro non valide. Saisissez un nombre compris entre 1 et %{maxSafeChainId}.", "hide_zero_balance_tokens_title": "Masquer les jetons sans solde", "hide_zero_balance_tokens_desc": "Empêche l’affichage des jetons sans solde dans votre liste de jetons.", + "haptic_feedback_title": "Haptic feedback", + "haptic_feedback_desc": "Feel subtle vibrations for confirmations, errors, and interactions. Disable to reduce haptic stimuli.", "token_detection_title": "Détection automatique des jetons", "token_detection_description": "Nous utilisons des API tierces pour détecter et afficher les nouveaux jetons envoyés à votre portefeuille. Désactivez cette option si vous ne souhaitez pas que l’application récupère les données de ces services.", "theme_button_text": "Changer le thème", @@ -3457,7 +3466,7 @@ "title": "Headless buy", "loading": "Preparing the buy flow…", "no_session": "Headless session is no longer active.", - "cancel": "Cancel" + "cancel": "Annuler" } }, "request_feature": "Demander une fonction", @@ -3572,6 +3581,39 @@ "title": "Card", "reset_onboarding_description": "Réinitialiser l’état d’intégration de « Card » pour recommencer le processus d’intégration depuis le début.", "reset_onboarding_button": "Réinitialiser l’état d’intégration" + }, + "haptics": { + "title": "Haptics", + "description": "Preview haptic feedback on a physical device. This bypasses in-app reduced-haptics and the remote kill switch so you can evaluate each pattern. Simulators often do not play haptics.", + "catalog_impacts_heading": "Catalog impact moments", + "notifications_heading": "Notification patterns", + "selection_heading": "Selection", + "notification_success_button": "Notification — Success", + "notification_error_button": "Notification — Error", + "notification_warning_button": "Notification — Warning", + "selection_button": "Selection", + "raw_impacts_heading": "Raw Expo impact styles", + "raw_impacts_desc": "Direct expo-haptics impact styles for comparison with the mapped catalog moments above.", + "impacts": { + "quick_amount_selection": "Impact — Quick amount selection", + "slider_tick": "Impact — Slider tick", + "edge_gesture_engage": "Impact — Edge gesture engage", + "page_navigation": "Impact — Page navigation", + "slider_grip": "Impact — Slider grip", + "tab_change": "Impact — Tab change", + "primary_cta": "Impact — Primary CTA", + "pull_to_refresh_engage": "Impact — Pull to refresh (engage)", + "pull_to_refresh": "Impact — Pull to refresh (release)", + "chart_crosshair": "Impact — Chart crosshair", + "follow_toggle": "Impact — Follow toggle" + }, + "raw_styles": { + "light": "Raw impact — Light", + "medium": "Raw impact — Medium", + "heavy": "Raw impact — Heavy", + "rigid": "Raw impact — Rigid", + "soft": "Raw impact — Soft" + } } }, "feature_flag_override": { @@ -6482,7 +6524,7 @@ "onboarding": { "step_progress": "Étape {{current}} sur {{total}}", "title": "Ajouter de l’argent", - "description": "Achetez des mUSD et commencez dès aujourd’hui à bénéficier d’un taux de rendement annuel.", + "description": "Fund your account and start earning APY.", "add": "Ajouter", "step2_title": "Obtenez votre carte MetaMask Card", "step2_description": "Dépensez l’argent que vous avez déposé sur votre compte Money tout en le faisant fructifier, partout où la carte Mastercard est acceptée.", @@ -6499,7 +6541,8 @@ "earnings": { "title": "Bénéfices", "lifetime": "Gains cumulés", - "projected": "Bénéfices prévus" + "projected": "Bénéfices prévus", + "info_label": "Earnings info" }, "how_it_works": { "title": "Comment ça marche ", @@ -6553,6 +6596,35 @@ "receive_external": "Receive from external wallet", "coming_soon": "Bientôt disponible" }, + "more_sheet": { + "title": "Plus", + "how_it_works": "Comment ça marche ", + "what_you_get": "Ce que vous obtenez", + "contact_support": "Contacter le service d’assistance" + }, + "transfer_sheet": { + "title": "Transfer money", + "between_accounts": "Between accounts", + "perps_account": "Perps account", + "predictions_account": "Predictions account", + "send_external": "Send to external address", + "withdraw_to_bank": "Withdraw to bank" + }, + "apy_tooltip": { + "title": "Annual Percentage Yield (APY)", + "paragraph_1": "Your Money account earns up to {{percentage}}% automatically.", + "paragraph_2": "The rate shown is an estimated Annual Percentage Yield (APY) based on current market conditions. APY is variable and may change due to various factors. The rate is generated by third-party DeFi platforms that deploy funds in blockchain protocols.", + "paragraph_3": "When you link your MetaMask Card, purchases will be deducted from this balance.", + "learn_more": "En savoir plus" + }, + "earnings_tooltip": { + "title": "Bénéfices", + "lifetime_heading": "Gains cumulés", + "lifetime_body": "The total yield you've earned since opening your Money account.", + "projected_heading": "Bénéfices prévus", + "projected_body": "Une projection de ce que vous gagneriez sur une année en fonction de votre solde et de votre taux actuels.", + "disclaimer": "Projection assumes {{percentage}}% Annual Percentage Yield (APY) remains unchanged for 1 year. APY is variable and may change due to various factors. No guarantee of return." + }, "activity": { "title": "Activité", "view_all": "Tout afficher", @@ -6917,7 +6989,9 @@ "perps_deposit": "Ajouter des fonds", "perps_withdraw": "Retirer", "predict_deposit": "Ajouter des fonds de prédiction", - "predict_withdraw": "Retirer" + "predict_withdraw": "Retirer", + "money_account_add_money": "Ajouter de l’argent", + "money_account_transfer_money": "Transfer money" }, "sub_title": { "permit": "Ce site demande l’autorisation de dépenser vos jetons.", @@ -6937,7 +7011,7 @@ "transaction_fee": "Nous échangerons vos jetons contre des USDC sur HyperCore, le réseau utilisé par les contrats à terme perpétuels. Les fournisseurs de services d’échange peuvent facturer des frais, mais MetaMask ne le fera pas." }, "predict_deposit": { - "transaction_fee": "Nous échangerons vos jetons contre des USDC.e sur Polygon, le réseau utilisé par « Prédictions ». Les fournisseurs de services d’échange peuvent facturer des frais, mais MetaMask ne le fera pas." + "transaction_fee": "We'll swap your tokens for pUSD on Polygon, the network used by Predictions. Swap providers may charge a fee, but MetaMask won't." }, "perps_withdraw": { "transaction_fee": "MetaMask échangera vos jetons pour vous. Aucuns frais MetaMask ne s’appliquent lorsque vous effectuez un échange contre des mUSD." @@ -7033,7 +7107,8 @@ "custom_amount": { "buy_button": "Acheter des cryptomonnaies", "buy_predict": "Ajoutez des fonds à votre portefeuille pour utiliser les Prédictions.", - "buy_perps": "Ajoutez des fonds à votre portefeuille pour utiliser les contrats à terme perpétuels." + "buy_perps": "Ajoutez des fonds à votre portefeuille pour utiliser les contrats à terme perpétuels.", + "projected_five_year_balance": "Projected 5-year balance:" }, "unlimited": "Illimité", "all": "Tout", @@ -7113,6 +7188,7 @@ "receive_at": "Recevoir à", "recipient": "Destinataire", "select_recipient": "Sélectionner le destinataire", + "select_account": "Sélectionner un compte", "external_account": "Compte externe", "error_banner_description": "Cette voie d’échange n’est pas disponible pour le moment. Essayez de modifier le montant, le réseau ou le jeton, et nous trouverons la meilleure option.", "stock_token_error_banner_description": "Cette voie d’échange n’est pas disponible pour le moment. Essayez de modifier le montant, le réseau ou le jeton, et nous trouverons la meilleure option.\n\nVeuillez noter que si vous essayez d’échanger des actions tokenisées Ondo, vous pouvez être soumis à des restrictions géographiques imposées par les États-Unis, l’Union européenne, le Royaume-Uni, le Brésil ou d’autres pays.", @@ -7791,6 +7867,7 @@ "enable_card_button_label": "Activer la carte", "enable_assets_button_label": "Activer les actifs", "spending_limit_warning": "Vous avez presque atteint votre limite de dépenses. Modifiez votre limite de dépenses pour éviter tout refus de paiement.", + "spending_limit_available": "disponible", "logout": "Déconnexion", "contact_support": "Contacter le service d’assistance", "logout_confirmation_title": "Voulez-vous vous déconnecter de votre compte MetaMask Card ?", @@ -7925,7 +8002,12 @@ "withdrawal_success": "Retrait effectué avec succès", "withdrawal_failed": "Le retrait a échoué. Veuillez réessayer.", "no_cashback": "Aucun cashback disponible", - "loading_error": "Échec du chargement du cashback. Veuillez réessayer." + "loading_error": "Échec du chargement du cashback. Veuillez réessayer.", + "funding_required": { + "title": "Set up Linea funding", + "description": "You need at least one approved funding source on Linea before redeeming cashback.", + "confirm_button_label": "Set up funding" + } }, "change_asset": { "title": "Modifier le jeton et le réseau", @@ -8030,7 +8112,8 @@ "title": "Sélectionnez le mode de paiement", "title_receive": "Sélectionner le jeton à recevoir", "no_gas": "Pas de solde natif pour le gaz", - "not_supported": "Non pris en charge" + "not_supported": "Non pris en charge", + "crypto": "Crypto" }, "connection_removed_modal": { "title": "Connexions supprimées", @@ -8365,6 +8448,8 @@ "ends_date": "Se termine le {{date}}", "ended_date": "A pris fin le {{date}}", "pill_up_next": "Bientôt disponible", + "up_next": "À venir", + "notify_me": "M’avertir", "pill_active": "Active", "pill_complete": "Terminé", "enter": "Entrer", @@ -8378,7 +8463,9 @@ "geo_locked_toast_title": "Non disponible dans votre région", "geo_locked_toast_description": "Cette campagne n’est pas disponible dans votre région. Revenez plus tard pour découvrir de nouvelles campagnes.", "geo_locked_cta": "Vérifier les conditions d’admissibilité", - "geo_loading": "Vérification de la région…" + "geo_loading": "Vérification de la région…", + "remind_me_success_toast": "We'll let you know when this campaign starts.", + "remind_me_save_error": "Could not save your reminder. Please try again." }, "campaign_mechanics": { "title": "Déroulement" @@ -8507,6 +8594,46 @@ "cancel": "Annuler", "confirm": "Je comprends" }, + "perps_trading_campaign": { + "title": "Perps Trading Competition", + "stats_title": "Stats", + "performance_title": "Performance", + "label_rank": "Rank", + "label_your_rank": "Your rank", + "label_pnl": "PnL", + "label_volume": "Volume", + "label_notional_volume": "Notional volume", + "label_margin": "Margin", + "label_margin_deployed": "Margin deployed", + "pending": "Pending", + "qualified": "Qualified", + "open_position_cta": "Open Position", + "leaderboard_title": "Leaderboard", + "leaderboard_total_participants": "{{count}} participants", + "last_updated": "Last updated: {{time}}", + "leaderboard_error_loading": "Failed to load leaderboard", + "leaderboard_error_loading_description": "Something went wrong while loading the leaderboard. Please try again.", + "leaderboard_not_yet_computed": "Leaderboard hasn't been computed yet. Check back soon.", + "leaderboard_powered_by_prefix": "Powered by ", + "leaderboard_hypertracker_brand": "HyperTracker", + "prize_pool_title": "Prize pool", + "prize_pool_current_label": "Current", + "prize_pool_next_label": "Next", + "prize_pool_volume_subtext": "{{current}} of {{target}} volume", + "prize_pool_max_tier_subtext": "{{maxThreshold}}+ volume — all milestones reached", + "prize_pool_max_badge": "Max", + "prize_pool_error_title": "Failed to load prize pool", + "prize_pool_error_description": "There was an error loading the prize pool. Please try again.", + "prize_pool_retry_button": "Retry", + "stats_notional_volume_threshold": "${{amount}} required", + "stats_qualified_title": "You're qualified", + "stats_qualified_description": "Keep trading to improve your PnL and climb the leaderboard before the competition ends.", + "stats_qualify_for_rank_title": "Qualify for this rank", + "stats_qualify_for_rank_description": "Trade {{notionalRemaining}} more in notional volume to become eligible.", + "stats_error_title": "Unable to load stats", + "stats_error_description": "We had a problem loading your stats. Please try again.", + "stats_retry": "Retry" + }, "campaigns_preview": { "title": "Campagnes", "coming_soon": "Bientôt disponible", @@ -8661,7 +8788,9 @@ }, "trending": { "title": "Explorer", - "trending_tokens": "Tendances", + "crypto_movers": "Crypto movers", + "perps_movers": "Perps movers", + "trending_tokens": "Tendance", "stocks": "Actions", "price_change": "Variation du prix", "all_networks": "Tous les réseaux", @@ -8672,6 +8801,17 @@ "1_hour": "1 heure", "5_minutes": "5 minutes", "networks": "Réseaux", + "ecosystems": "Ecosystems", + "ecosystems_subtitle": "Explore dApps, tokens, and NFTs across chains on MetaMask Portfolio", + "all_sports": "Popular sports", + "basketball": "Basketball", + "football": "Football", + "soccer": "Soccer", + "tennis": "Tennis", + "f1": "F1", + "golf": "Golf", + "all_sports_no_markets": "No open markets in this category yet. Check back soon or pick another sport above.", + "load_more": "Load more", "sort_by": "Trier par", "volume": "Volume", "market_cap": "Capitalisation boursière", @@ -8681,8 +8821,17 @@ "search_placeholder": "Rechercher des jetons, des sites, des URL", "cancel": "Annuler", "perps": "Perps", + "rwa_perps_section": "Marchés", + "macro_stocks_commodity_perps": "Stocks & commodities", + "macro_pill_stocks": "Actions", + "macro_pill_commodities": "Matières premières", + "rwa_pill_commodities": "Matières premières", + "rwa_pill_stocks": "Actions", + "rwa_pill_forex": "Forex", + "crypto_perps_section": "Perps", "predictions": "Prédictions", "no_results": "Aucun résultat trouvé", + "popular": "Populaire", "sites": "Sites", "popular_sites": "Sites populaires", "search_sites": "Rechercher des sites", @@ -8698,6 +8847,14 @@ "empty_search_result_state": { "title": "Aucun jeton trouvé", "description": "Nous n’avons pas trouvé ce jeton" + }, + "tabs": { + "now": "Maintenant", + "macro": "Macroéconomie", + "rwas": "RWAs", + "crypto": "Crypto", + "sports": "Sports", + "dapps": "Sites" } }, "ota_update_modal": { @@ -8715,7 +8872,9 @@ }, "common": { "cancel": "Annuler", - "continue": "Continuer" + "continue": "Continuer", + "learn_more": "En savoir plus", + "try_again": "Réessayez" }, "connecting": { "title": "Connexion de votre {{device}}…", @@ -8761,6 +8920,7 @@ "bluetooth_off": "Veuillez activer le Bluetooth pour établir une connexion avec votre appareil", "bluetooth_scan_failed": "Échec de la recherche d’appareils. Veuillez réessayer", "bluetooth_connection_failed": "La connexion à votre appareil a échoué. Veuillez réessayer", + "camera_permission_denied": "Camera permission is required to scan QR codes. Please enable it in your device settings", "not_supported": "Cette opération n’est pas prise en charge", "unknown_error": "Assurez-vous que votre {{device}} est configuré avec la phrase de récupération secrète ou la phrase secrète de ce compte" }, @@ -8784,8 +8944,35 @@ "location_permission_denied": "Autorisation de localisation requise", "nearby_devices_permission_denied": "L’autorisation d’accès aux appareils à proximité est requise", "scan_failed": "La recherche a échoué", + "camera_permission_denied": "Camera Permission Required", "something_went_wrong": "Quelque chose a mal tourné" }, + "qr_scan_errors": { + "non_ur_qr_scanned": { + "pair": { + "title": "Scan wallet sync QR code", + "body": "Go back to your hardware wallet and find the option to share or export your accounts, choose MetaMask, then scan the QR code." + }, + "sign": { + "title": "Scan signature QR code", + "body": "Go back to your hardware wallet and approve the transaction, then scan the QR code it shows." + } + }, + "wrong_ur_type": { + "pair": { + "title": "Wrong QR code", + "body": "Go back to your hardware wallet, choose MetaMask as the wallet type, then generate and scan a new QR code." + }, + "sign": { + "title": "Wrong QR code", + "body": "Go back to your hardware wallet and approve the transaction, then scan the QR code it shows." + } + }, + "ur_decode_error": { + "title": "Couldn't read QR code", + "body": "Make sure the QR code is fully visible and well-lit, and try again. If the issue continues, you may need to update your hardware wallet's firmware." + } + }, "device_selection": { "title": "Sélectionnez {{device}}", "scanning": "Recherche d’appareils…", @@ -8803,6 +8990,14 @@ "cash": "Money", "cash_empty_description": "Vous n’avez pas encore de mUSD. Convertissez des stablecoins en mUSD depuis la section « Money » de la page d’accueil.", "cash_empty_description_network_filter": "Pas de mUSD sur ce réseau. Changez de réseau pour consulter votre solde de mUSD.", + "cash_empty_state": { + "get_started": "Commencer", + "earn_apy": "Earn {{percentage}}% APY" + }, + "cash_filled_state": { + "add": "Ajouter", + "apy": "Taux de rendement annuel de {{percentage}} %" + }, "tokens": "Jetons", "perpetuals": "Contrats perpétuels", "predictions": "Prédictions", @@ -8818,7 +9013,7 @@ }, "defi": "DeFi", "nfts": "NFT", - "trending_tokens": "Tendances", + "trending_tokens": "Tendance", "trending_perpetuals": "Contrats à terme perpétuels populaires", "trending_predictions": "Prédictions populaires", "import_nfts": "Importer des NFT", @@ -8832,5 +9027,8 @@ "unable_to_load": "Impossible de charger {{section}}", "retry": "Réessayer" } + }, + "sites": { + "popular": "Populaire" } } diff --git a/locales/languages/hi.json b/locales/languages/hi.json index 2e481071a40..962fd14ccc8 100644 --- a/locales/languages/hi.json +++ b/locales/languages/hi.json @@ -634,7 +634,8 @@ "trade": "ट्रेड करें", "settings": "सेटिंग्स", "rewards": "पुरस्कार", - "trending": "एक्सप्लोर करें" + "trending": "एक्सप्लोर करें", + "money": "वित्त" }, "drawer": { "send_button": "भेजें", @@ -2446,6 +2447,12 @@ } } }, + "payment": { + "change_payment_method": "Change Payment Method", + "add_funds": "फंड जोड़ें", + "add": "जोड़ें", + "predict_balance": "Predict balance" + }, "order": { "available": "उपलब्ध", "cashed_out": "कैश आउट किया गया", @@ -3314,6 +3321,8 @@ "invalid_number_range": "अमान्य संख्या। 1 और %{maxSafeChainId} के बीच एक संख्या दर्ज करें", "hide_zero_balance_tokens_title": "बिना बैलेंस वाले टोकन छिपाएं", "hide_zero_balance_tokens_desc": "आपके टोकन की सूची में बिना बैलेंस वाले टोकन के प्रदर्शन को रोकें।", + "haptic_feedback_title": "Haptic feedback", + "haptic_feedback_desc": "Feel subtle vibrations for confirmations, errors, and interactions. Disable to reduce haptic stimuli.", "token_detection_title": "टोकन ऑटो-डिटेक्ट करें", "token_detection_description": "हम तीसरे-पक्ष API का उपयोग आपके वॉलेट में भेजे गए नए टोकन का पता लगाने और प्रदर्शन में करते हैं। बंद कर दें यदि आप नहीं चाहते कि ऐप उन सेवाओं से डेटा अलग करें।", "theme_button_text": "थीम बदलें", @@ -3457,7 +3466,7 @@ "title": "Headless buy", "loading": "Preparing the buy flow…", "no_session": "Headless session is no longer active.", - "cancel": "Cancel" + "cancel": "कैंसिल करें" } }, "request_feature": "एक सुविधा का रिक्वेस्ट करें", @@ -3572,6 +3581,39 @@ "title": "कार्ड", "reset_onboarding_description": "ऑनबोर्डिंग प्रक्रिया को शुरुआत से दोबारा शुरू करने के लिए कार्ड की ऑनबोर्डिंग स्टेट रीसेट करें।", "reset_onboarding_button": "ऑनबोर्डिंग स्टेट रीसेट करें" + }, + "haptics": { + "title": "Haptics", + "description": "Preview haptic feedback on a physical device. This bypasses in-app reduced-haptics and the remote kill switch so you can evaluate each pattern. Simulators often do not play haptics.", + "catalog_impacts_heading": "Catalog impact moments", + "notifications_heading": "Notification patterns", + "selection_heading": "Selection", + "notification_success_button": "Notification — Success", + "notification_error_button": "Notification — Error", + "notification_warning_button": "Notification — Warning", + "selection_button": "Selection", + "raw_impacts_heading": "Raw Expo impact styles", + "raw_impacts_desc": "Direct expo-haptics impact styles for comparison with the mapped catalog moments above.", + "impacts": { + "quick_amount_selection": "Impact — Quick amount selection", + "slider_tick": "Impact — Slider tick", + "edge_gesture_engage": "Impact — Edge gesture engage", + "page_navigation": "Impact — Page navigation", + "slider_grip": "Impact — Slider grip", + "tab_change": "Impact — Tab change", + "primary_cta": "Impact — Primary CTA", + "pull_to_refresh_engage": "Impact — Pull to refresh (engage)", + "pull_to_refresh": "Impact — Pull to refresh (release)", + "chart_crosshair": "Impact — Chart crosshair", + "follow_toggle": "Impact — Follow toggle" + }, + "raw_styles": { + "light": "Raw impact — Light", + "medium": "Raw impact — Medium", + "heavy": "Raw impact — Heavy", + "rigid": "Raw impact — Rigid", + "soft": "Raw impact — Soft" + } } }, "feature_flag_override": { @@ -6482,7 +6524,7 @@ "onboarding": { "step_progress": "{{total}} का स्टेप {{current}}", "title": "धन जोड़ें", - "description": "mUSD खरीदें और आज ही एपीवाय कमाना शुरू करें।", + "description": "Fund your account and start earning APY.", "add": "जोड़ें", "step2_title": "अपना MetaMask कार्ड पाएं", "step2_description": "अपना Money बैलेंस पाने के साथ उसे खर्च करें, जहां भी मास्टरकार्ड स्वीकार किया जाता हो।", @@ -6499,7 +6541,8 @@ "earnings": { "title": "कमाई", "lifetime": "जीवन भर की कमाई", - "projected": "अनुमानित कमाई" + "projected": "अनुमानित कमाई", + "info_label": "Earnings info" }, "how_it_works": { "title": "ये कैसे काम करता है", @@ -6553,6 +6596,35 @@ "receive_external": "Receive from external wallet", "coming_soon": "जल्द आ रहा है" }, + "more_sheet": { + "title": "अधिक", + "how_it_works": "ये कैसे काम करता है", + "what_you_get": "आपको क्या मिलता है", + "contact_support": "सपोर्ट टीम से कॉन्टेक्ट करें" + }, + "transfer_sheet": { + "title": "Transfer money", + "between_accounts": "Between accounts", + "perps_account": "Perps account", + "predictions_account": "Predictions account", + "send_external": "Send to external address", + "withdraw_to_bank": "Withdraw to bank" + }, + "apy_tooltip": { + "title": "Annual Percentage Yield (APY)", + "paragraph_1": "Your Money account earns up to {{percentage}}% automatically.", + "paragraph_2": "The rate shown is an estimated Annual Percentage Yield (APY) based on current market conditions. APY is variable and may change due to various factors. The rate is generated by third-party DeFi platforms that deploy funds in blockchain protocols.", + "paragraph_3": "When you link your MetaMask Card, purchases will be deducted from this balance.", + "learn_more": "ज़्यादा जानें" + }, + "earnings_tooltip": { + "title": "कमाई", + "lifetime_heading": "जीवन भर की कमाई", + "lifetime_body": "The total yield you've earned since opening your Money account.", + "projected_heading": "अनुमानित कमाई", + "projected_body": "आपके वर्तमान बैलेंस और ब्याज दर के आधार पर एक वर्ष में आपकी संभावित कमाई का अनुमान।", + "disclaimer": "Projection assumes {{percentage}}% Annual Percentage Yield (APY) remains unchanged for 1 year. APY is variable and may change due to various factors. No guarantee of return." + }, "activity": { "title": "गतिविधि", "view_all": "सभी देखें", @@ -6917,7 +6989,9 @@ "perps_deposit": "फंड जोड़ें", "perps_withdraw": "निकालें", "predict_deposit": "प्रिडिक्शन फ़ंड जोड़ें", - "predict_withdraw": "निकालें" + "predict_withdraw": "निकालें", + "money_account_add_money": "धन जोड़ें", + "money_account_transfer_money": "Transfer money" }, "sub_title": { "permit": "यह साइट आपके टोकन खर्च करने की अनुमति चाहती है।", @@ -6937,7 +7011,7 @@ "transaction_fee": "हम आपके टोकन को HyperCore पर USDC में स्वैप करेंगे, जो पर्प्स द्वारा इस्तेमाल होने वाला नेटवर्क है। स्वैप प्रदाता फीस ले सकते हैं, लेकिन MetaMask कोई फीस नहीं लेगा।" }, "predict_deposit": { - "transaction_fee": "हम आपके टोकन को Polygon पर USDC.e में स्वैप करेंगे, जो प्रिडिक्शन्स द्वारा इस्तेमाल होने वाला नेटवर्क है। स्वैप प्रदाता फीस ले सकते हैं, लेकिन MetaMask कोई फीस नहीं लेगा।" + "transaction_fee": "We'll swap your tokens for pUSD on Polygon, the network used by Predictions. Swap providers may charge a fee, but MetaMask won't." }, "perps_withdraw": { "transaction_fee": "MetaMask आपके लिए आपके पसंदीदा टोकन में स्वैप करेगा। जब आप mUSD में स्वैप करते हैं तो कोई MetaMask शुल्क लागू नहीं होता है।" @@ -7033,7 +7107,8 @@ "custom_amount": { "buy_button": "क्रिप्टो खरीदें", "buy_predict": "प्रेडिक्शंस का उपयोग करने के लिए अपने वॉलेट में फंड्स जोड़ें।", - "buy_perps": "पर्प्स का उपयोग करने के लिए अपने वॉलेट में फंड्स जोड़ें।" + "buy_perps": "पर्प्स का उपयोग करने के लिए अपने वॉलेट में फंड्स जोड़ें।", + "projected_five_year_balance": "Projected 5-year balance:" }, "unlimited": "असीमित", "all": "सभी", @@ -7113,6 +7188,7 @@ "receive_at": "यहां प्राप्त करें", "recipient": "प्राप्तकर्ता", "select_recipient": "प्राप्तकर्ता चुनें", + "select_account": "अकाउंट चुनें", "external_account": "बाहरी अकाउंट", "error_banner_description": "यह ट्रेड रूट अभी उपलब्ध नहीं है। राशि, नेटवर्क या टोकन बदलने का प्रयास करें और हम सबसे अच्छा विकल्प ढूँढ लेंगे।", "stock_token_error_banner_description": "यह ट्रेड रूट अभी उपलब्ध नहीं है। अमाउंट, नेटवर्क या टोकन बदलने की कोशिश करें और हम सबसे अच्छा विकल्प ढूंढ लेंगे।", @@ -7791,6 +7867,7 @@ "enable_card_button_label": "कार्ड चालू करें", "enable_assets_button_label": "एसेट्स चालू करें", "spending_limit_warning": "आप अपनी खर्च सीमा के करीब हैं। अस्वीकृतियों से बचने के लिए अपडेट करें।", + "spending_limit_available": "उपलब्ध", "logout": "लॉग आउट करें", "contact_support": "सपोर्ट टीम से कॉन्टेक्ट करें", "logout_confirmation_title": "कार्ड से लॉग आउट करें?", @@ -7925,7 +8002,12 @@ "withdrawal_success": "विदड्रॉवल सफलतापूर्वक पूरा हुआ", "withdrawal_failed": "विदड्रॉवल नहीं हो पाया। कृपया फिर से प्रयास करें।", "no_cashback": "कोई कैशबैक उपलब्ध नहीं है", - "loading_error": "कैशबैक लोड करना नहीं हो पाया। कृपया फिर से प्रयास करें।" + "loading_error": "कैशबैक लोड करना नहीं हो पाया। कृपया फिर से प्रयास करें।", + "funding_required": { + "title": "Set up Linea funding", + "description": "You need at least one approved funding source on Linea before redeeming cashback.", + "confirm_button_label": "Set up funding" + } }, "change_asset": { "title": "टोकन और नेटवर्क बदलें", @@ -8030,7 +8112,8 @@ "title": "भुगतान विधि चुनें", "title_receive": "प्राप्त करने के लिए टोकन चुनें", "no_gas": "गैस के लिए कोई नेटिव बैलेंस नहीं है", - "not_supported": "सपोर्ट नहीं किया गया" + "not_supported": "सपोर्ट नहीं किया गया", + "crypto": "क्रिप्टो" }, "connection_removed_modal": { "title": "कनेक्शन हटा दिए गए", @@ -8365,6 +8448,8 @@ "ends_date": "{{date}} को समाप्त होता है", "ended_date": "{{date}} को समाप्त हुआ", "pill_up_next": "जल्द आ रहा है", + "up_next": "आगे आने वाला है", + "notify_me": "मुझे सूचित करें", "pill_active": "लाइव", "pill_complete": "पूरा", "enter": "एंटर करें", @@ -8378,7 +8463,9 @@ "geo_locked_toast_title": "आपके इलाके में उपलब्ध नहीं है", "geo_locked_toast_description": "यह कैंपेन आपके यहां उपलब्ध नहीं है। नए कैंपेन के लिए बाद में फिर से देखें।", "geo_locked_cta": "योग्यता चेक करें", - "geo_loading": "क्षेत्र की जांच की जा रही है…" + "geo_loading": "क्षेत्र की जांच की जा रही है…", + "remind_me_success_toast": "We'll let you know when this campaign starts.", + "remind_me_save_error": "Could not save your reminder. Please try again." }, "campaign_mechanics": { "title": "मैकेनिक्स" @@ -8507,6 +8594,46 @@ "cancel": "कैंसिल करें", "confirm": "मैं समझता हूं" }, + "perps_trading_campaign": { + "title": "Perps Trading Competition", + "stats_title": "Stats", + "performance_title": "Performance", + "label_rank": "Rank", + "label_your_rank": "Your rank", + "label_pnl": "PnL", + "label_volume": "Volume", + "label_notional_volume": "Notional volume", + "label_margin": "Margin", + "label_margin_deployed": "Margin deployed", + "pending": "Pending", + "qualified": "Qualified", + "open_position_cta": "Open Position", + "leaderboard_title": "Leaderboard", + "leaderboard_total_participants": "{{count}} participants", + "last_updated": "Last updated: {{time}}", + "leaderboard_error_loading": "Failed to load leaderboard", + "leaderboard_error_loading_description": "Something went wrong while loading the leaderboard. Please try again.", + "leaderboard_not_yet_computed": "Leaderboard hasn't been computed yet. Check back soon.", + "leaderboard_powered_by_prefix": "Powered by ", + "leaderboard_hypertracker_brand": "HyperTracker", + "prize_pool_title": "Prize pool", + "prize_pool_current_label": "Current", + "prize_pool_next_label": "Next", + "prize_pool_volume_subtext": "{{current}} of {{target}} volume", + "prize_pool_max_tier_subtext": "{{maxThreshold}}+ volume — all milestones reached", + "prize_pool_max_badge": "Max", + "prize_pool_error_title": "Failed to load prize pool", + "prize_pool_error_description": "There was an error loading the prize pool. Please try again.", + "prize_pool_retry_button": "Retry", + "stats_notional_volume_threshold": "${{amount}} required", + "stats_qualified_title": "You're qualified", + "stats_qualified_description": "Keep trading to improve your PnL and climb the leaderboard before the competition ends.", + "stats_qualify_for_rank_title": "Qualify for this rank", + "stats_qualify_for_rank_description": "Trade {{notionalRemaining}} more in notional volume to become eligible.", + "stats_error_title": "Unable to load stats", + "stats_error_description": "We had a problem loading your stats. Please try again.", + "stats_retry": "Retry" + }, "campaigns_preview": { "title": "कैंपेन", "coming_soon": "जल्द आ रहा है", @@ -8661,6 +8788,8 @@ }, "trending": { "title": "एक्सप्लोर करें", + "crypto_movers": "Crypto movers", + "perps_movers": "Perps movers", "trending_tokens": "ट्रेंडिंग", "stocks": "स्टॉक्स", "price_change": "प्राइस में बदलाव", @@ -8672,6 +8801,17 @@ "1_hour": "1 घंटा", "5_minutes": "5 मिनट", "networks": "नेटवर्क", + "ecosystems": "Ecosystems", + "ecosystems_subtitle": "Explore dApps, tokens, and NFTs across chains on MetaMask Portfolio", + "all_sports": "Popular sports", + "basketball": "Basketball", + "football": "Football", + "soccer": "Soccer", + "tennis": "Tennis", + "f1": "F1", + "golf": "Golf", + "all_sports_no_markets": "No open markets in this category yet. Check back soon or pick another sport above.", + "load_more": "Load more", "sort_by": "इसके अनुसार क्रमबद्ध करें", "volume": "वॉल्यूम", "market_cap": "मार्केट कैप", @@ -8681,8 +8821,17 @@ "search_placeholder": "टोकन, साइट, URL ढूंढें", "cancel": "कैंसिल करें", "perps": "पर्प्स", + "rwa_perps_section": "मार्केट", + "macro_stocks_commodity_perps": "Stocks & commodities", + "macro_pill_stocks": "स्टॉक्स", + "macro_pill_commodities": "कमोडिटीज़", + "rwa_pill_commodities": "कमोडिटीज़", + "rwa_pill_stocks": "स्टॉक्स", + "rwa_pill_forex": "फॉरेक्स", + "crypto_perps_section": "पर्प्स", "predictions": "प्रेडिक्शंस", "no_results": "कोई रिज़ल्ट नहीं मिला", + "popular": "लोकप्रिय", "sites": "साइट्स", "popular_sites": "पॉपुलर साइटें", "search_sites": "साइट ढूंढें", @@ -8698,6 +8847,14 @@ "empty_search_result_state": { "title": "कोई टोकन नहीं मिला", "description": "हमें यह टोकन नहीं मिला" + }, + "tabs": { + "now": "अब", + "macro": "मैक्रो", + "rwas": "RWAs", + "crypto": "क्रिप्टो", + "sports": "स्पोर्ट्स", + "dapps": "साइट्स" } }, "ota_update_modal": { @@ -8715,7 +8872,9 @@ }, "common": { "cancel": "कैंसिल करें", - "continue": "जारी रखें" + "continue": "जारी रखें", + "learn_more": "ज़्यादा जानें", + "try_again": "फिर से प्रयास करें" }, "connecting": { "title": "आपका {{device}} कनेक्ट हो रहा है...", @@ -8761,6 +8920,7 @@ "bluetooth_off": "कृपया अपने डिवाइस से कनेक्ट करने के लिए ब्लूटूथ चालू करें", "bluetooth_scan_failed": "डिवाइस स्कैन नहीं हो पाया। कृपया दोबारा प्रयास करें", "bluetooth_connection_failed": "आपके डिवाइस से कनेक्शन नहीं हो पाया। कृपया फिर से कोशिश करें", + "camera_permission_denied": "Camera permission is required to scan QR codes. Please enable it in your device settings", "not_supported": "यह ऑपरेशन सपोर्टेड नहीं है", "unknown_error": "सुनिश्चित करें कि आपका {{device}} इस अकाउंट के लिए सीक्रेट रिकवरी फ्रेज़ या पासफ़्रेज़ के साथ सेटअप किया गया है" }, @@ -8784,8 +8944,35 @@ "location_permission_denied": "लोकेशन की अनुमति आवश्यक है", "nearby_devices_permission_denied": "नज़दीकी डिवाइस की अनुमति आवश्यक है", "scan_failed": "स्कैन नहीं हो पाया", + "camera_permission_denied": "Camera Permission Required", "something_went_wrong": "कुछ गलत हो गया" }, + "qr_scan_errors": { + "non_ur_qr_scanned": { + "pair": { + "title": "Scan wallet sync QR code", + "body": "Go back to your hardware wallet and find the option to share or export your accounts, choose MetaMask, then scan the QR code." + }, + "sign": { + "title": "Scan signature QR code", + "body": "Go back to your hardware wallet and approve the transaction, then scan the QR code it shows." + } + }, + "wrong_ur_type": { + "pair": { + "title": "Wrong QR code", + "body": "Go back to your hardware wallet, choose MetaMask as the wallet type, then generate and scan a new QR code." + }, + "sign": { + "title": "Wrong QR code", + "body": "Go back to your hardware wallet and approve the transaction, then scan the QR code it shows." + } + }, + "ur_decode_error": { + "title": "Couldn't read QR code", + "body": "Make sure the QR code is fully visible and well-lit, and try again. If the issue continues, you may need to update your hardware wallet's firmware." + } + }, "device_selection": { "title": "{{device}} चुनें", "scanning": "डिवाइस के लिए स्कैन किया जा रहा है...", @@ -8803,6 +8990,14 @@ "cash": "वित्त", "cash_empty_description": "आपके पास अभी तक कोई mUSD नहीं है। होमपेज पर Money सेक्शन से स्टेबलकॉइन को mUSD में कन्वर्ट करें।", "cash_empty_description_network_filter": "इस नेटवर्क पर कोई mUSD नहीं है। अपना mUSD देखने के लिए नेटवर्क बदलें।", + "cash_empty_state": { + "get_started": "शुरू करें", + "earn_apy": "Earn {{percentage}}% APY" + }, + "cash_filled_state": { + "add": "जोड़ें", + "apy": "{{percentage}}% APY" + }, "tokens": "टोकन", "perpetuals": "परपेचुअल्स", "predictions": "प्रेडिक्शंस", @@ -8832,5 +9027,8 @@ "unable_to_load": "{{section}} लोड करने में असमर्थ", "retry": "फिर से प्रयास करें" } + }, + "sites": { + "popular": "लोकप्रिय" } } diff --git a/locales/languages/id.json b/locales/languages/id.json index 4dc6eb5737e..487c8399e66 100644 --- a/locales/languages/id.json +++ b/locales/languages/id.json @@ -634,7 +634,8 @@ "trade": "Berdagang", "settings": "Pengaturan", "rewards": "Reward", - "trending": "Jelajahi" + "trending": "Jelajahi", + "money": "Dana" }, "drawer": { "send_button": "Kirim", @@ -2446,6 +2447,12 @@ } } }, + "payment": { + "change_payment_method": "Change Payment Method", + "add_funds": "Tambahkan Dana", + "add": "Tambah", + "predict_balance": "Predict balance" + }, "order": { "available": "Tersedia", "cashed_out": "Dicairkan", @@ -3314,6 +3321,8 @@ "invalid_number_range": "Nomor tidak valid. Masukkan angka antara 1 dan %{maxSafeChainId}", "hide_zero_balance_tokens_title": "Sembunyikan token tanpa saldo", "hide_zero_balance_tokens_desc": "Mencegah token tanpa saldo ditampilkan di daftar token Anda.", + "haptic_feedback_title": "Haptic feedback", + "haptic_feedback_desc": "Feel subtle vibrations for confirmations, errors, and interactions. Disable to reduce haptic stimuli.", "token_detection_title": "Autodeteksi token", "token_detection_description": "Kami menggunakan API pihak ketiga untuk mendeteksi dan menampilkan token baru yang dikirim ke dompet Anda. Nonaktifkan jika Anda tidak ingin aplikasi menarik data dari layanan tersebut.", "theme_button_text": "Ubah tema", @@ -3457,7 +3466,7 @@ "title": "Headless buy", "loading": "Preparing the buy flow…", "no_session": "Headless session is no longer active.", - "cancel": "Cancel" + "cancel": "Batalkan" } }, "request_feature": "Minta fitur", @@ -3572,6 +3581,39 @@ "title": "Kartu", "reset_onboarding_description": "Reset status pendaftaran Kartu untuk memulai alur pendaftaran dari awal.", "reset_onboarding_button": "Reset Status Pendaftaran" + }, + "haptics": { + "title": "Haptics", + "description": "Preview haptic feedback on a physical device. This bypasses in-app reduced-haptics and the remote kill switch so you can evaluate each pattern. Simulators often do not play haptics.", + "catalog_impacts_heading": "Catalog impact moments", + "notifications_heading": "Notification patterns", + "selection_heading": "Selection", + "notification_success_button": "Notification — Success", + "notification_error_button": "Notification — Error", + "notification_warning_button": "Notification — Warning", + "selection_button": "Selection", + "raw_impacts_heading": "Raw Expo impact styles", + "raw_impacts_desc": "Direct expo-haptics impact styles for comparison with the mapped catalog moments above.", + "impacts": { + "quick_amount_selection": "Impact — Quick amount selection", + "slider_tick": "Impact — Slider tick", + "edge_gesture_engage": "Impact — Edge gesture engage", + "page_navigation": "Impact — Page navigation", + "slider_grip": "Impact — Slider grip", + "tab_change": "Impact — Tab change", + "primary_cta": "Impact — Primary CTA", + "pull_to_refresh_engage": "Impact — Pull to refresh (engage)", + "pull_to_refresh": "Impact — Pull to refresh (release)", + "chart_crosshair": "Impact — Chart crosshair", + "follow_toggle": "Impact — Follow toggle" + }, + "raw_styles": { + "light": "Raw impact — Light", + "medium": "Raw impact — Medium", + "heavy": "Raw impact — Heavy", + "rigid": "Raw impact — Rigid", + "soft": "Raw impact — Soft" + } } }, "feature_flag_override": { @@ -6482,7 +6524,7 @@ "onboarding": { "step_progress": "Langkah {{current}} dari {{total}}", "title": "Tambahkan uang", - "description": "Beli mUSD dan mulai dapatkan APY hari ini.", + "description": "Fund your account and start earning APY.", "add": "Tambah", "step2_title": "Dapatkan Kartu MetaMask", "step2_description": "Gunakan saldo Dana selagi masih menghasilkan, di mana pun Mastercard diterima.", @@ -6499,7 +6541,8 @@ "earnings": { "title": "Pendapatan", "lifetime": "Penghasilan seumur hidup", - "projected": "Proyeksi pendapatan" + "projected": "Proyeksi pendapatan", + "info_label": "Earnings info" }, "how_it_works": { "title": "Cara kerjanya", @@ -6553,6 +6596,35 @@ "receive_external": "Receive from external wallet", "coming_soon": "Segera hadir" }, + "more_sheet": { + "title": "Selengkapnya", + "how_it_works": "Cara kerjanya", + "what_you_get": "Yang Anda dapatkan", + "contact_support": "Hubungi dukungan" + }, + "transfer_sheet": { + "title": "Transfer money", + "between_accounts": "Between accounts", + "perps_account": "Perps account", + "predictions_account": "Predictions account", + "send_external": "Send to external address", + "withdraw_to_bank": "Withdraw to bank" + }, + "apy_tooltip": { + "title": "Annual Percentage Yield (APY)", + "paragraph_1": "Your Money account earns up to {{percentage}}% automatically.", + "paragraph_2": "The rate shown is an estimated Annual Percentage Yield (APY) based on current market conditions. APY is variable and may change due to various factors. The rate is generated by third-party DeFi platforms that deploy funds in blockchain protocols.", + "paragraph_3": "When you link your MetaMask Card, purchases will be deducted from this balance.", + "learn_more": "Pelajari selengkapnya" + }, + "earnings_tooltip": { + "title": "Pendapatan", + "lifetime_heading": "Penghasilan seumur hidup", + "lifetime_body": "The total yield you've earned since opening your Money account.", + "projected_heading": "Proyeksi pendapatan", + "projected_body": "Proyeksi pendapatan Anda selama setahun berdasarkan saldo dan suku bunga saat ini.", + "disclaimer": "Projection assumes {{percentage}}% Annual Percentage Yield (APY) remains unchanged for 1 year. APY is variable and may change due to various factors. No guarantee of return." + }, "activity": { "title": "Aktivitas", "view_all": "Lihat semua", @@ -6917,7 +6989,9 @@ "perps_deposit": "Tambahkan dana", "perps_withdraw": "Tarik", "predict_deposit": "Tambahkan dana Prediction", - "predict_withdraw": "Tarik" + "predict_withdraw": "Tarik", + "money_account_add_money": "Tambahkan uang", + "money_account_transfer_money": "Transfer money" }, "sub_title": { "permit": "Situs ini meminta izin untuk menggunakan token Anda.", @@ -6937,7 +7011,7 @@ "transaction_fee": "Kami akan menukar token Anda dengan USDC di HyperCore, jaringan yang digunakan oleh Perp. Penyedia swap mungkin akan mengenakan biaya, tetapi MetaMask tidak." }, "predict_deposit": { - "transaction_fee": "Kami akan menukar token Anda dengan USDC.e di Polygon, jaringan yang digunakan oleh Predictions. Penyedia swap mungkin akan mengenakan biaya, tetapi MetaMask tidak." + "transaction_fee": "We'll swap your tokens for pUSD on Polygon, the network used by Predictions. Swap providers may charge a fee, but MetaMask won't." }, "perps_withdraw": { "transaction_fee": "MetaMask akan menukar token Anda ke token yang diinginkan. MetaMask tidak mengenakan biaya saat Anda menukar ke mUSD." @@ -7033,7 +7107,8 @@ "custom_amount": { "buy_button": "Beli kripto", "buy_predict": "Tambahkan dana ke dompet Anda untuk menggunakan Prediksi.", - "buy_perps": "Tambahkan dana ke dompet Anda untuk menggunakan Perp." + "buy_perps": "Tambahkan dana ke dompet Anda untuk menggunakan Perp.", + "projected_five_year_balance": "Projected 5-year balance:" }, "unlimited": "Tak terbatas", "all": "Semua", @@ -7113,6 +7188,7 @@ "receive_at": "Terima di", "recipient": "Penerima", "select_recipient": "Pilih penerima", + "select_account": "Pilih akun", "external_account": "Akun eksternal", "error_banner_description": "Rute perdagangan ini tidak tersedia untuk saat ini. Coba ubah jumlah, jaringan, atau token, dan kami akan mencari opsi terbaik.", "stock_token_error_banner_description": "Rute perdagangan ini tidak tersedia untuk saat ini. Coba ubah jumlah, jaringan, atau token, dan kami akan menemukan opsi terbaik.\n\nIngatlah bahwa jika Anda mencoba memperdagangkan Saham Token Ondo, Anda mungkin akan dibatasi secara geografis, misalnya melalui AS, Uni Eropa, Britania Raya, dan Brasil.", @@ -7791,6 +7867,7 @@ "enable_card_button_label": "Aktifkan kartu", "enable_assets_button_label": "Aktifkan aset", "spending_limit_warning": "Batas penggunaan hampir tercapai. Perbarui untuk menghindari penolakan.", + "spending_limit_available": "tersedia", "logout": "Keluar", "contact_support": "Hubungi dukungan", "logout_confirmation_title": "Keluar dari Kartu?", @@ -7925,7 +8002,12 @@ "withdrawal_success": "Penarikan berhasil diselesaikan", "withdrawal_failed": "Penarikan gagal. Coba lagi.", "no_cashback": "Cashback tidak tersedia", - "loading_error": "Gagal memuat cashback. Coba lagi." + "loading_error": "Gagal memuat cashback. Coba lagi.", + "funding_required": { + "title": "Set up Linea funding", + "description": "You need at least one approved funding source on Linea before redeeming cashback.", + "confirm_button_label": "Set up funding" + } }, "change_asset": { "title": "Ubah token dan jaringan", @@ -8030,7 +8112,8 @@ "title": "Pilih metode pembayaran", "title_receive": "Pilih terima token", "no_gas": "Tidak ada saldo asli untuk gas", - "not_supported": "Tidak didukung" + "not_supported": "Tidak didukung", + "crypto": "Kripto" }, "connection_removed_modal": { "title": "Koneksi dihapus", @@ -8365,6 +8448,8 @@ "ends_date": "Berakhir {{date}}", "ended_date": "Berakhir pada {{date}}", "pill_up_next": "Segera hadir", + "up_next": "Selanjutnya", + "notify_me": "Beri tahu saya", "pill_active": "Langsung", "pill_complete": "Selesaikan", "enter": "Masuk", @@ -8378,7 +8463,9 @@ "geo_locked_toast_title": "Tidak tersedia di wilayah Anda", "geo_locked_toast_description": "Kampanye ini tidak tersedia di lokasi Anda. Periksa kembali nanti untuk kampanye baru.", "geo_locked_cta": "Periksa kelayakan", - "geo_loading": "Memeriksa wilayah..." + "geo_loading": "Memeriksa wilayah...", + "remind_me_success_toast": "We'll let you know when this campaign starts.", + "remind_me_save_error": "Could not save your reminder. Please try again." }, "campaign_mechanics": { "title": "Mekanika" @@ -8507,6 +8594,46 @@ "cancel": "Batalkan", "confirm": "Saya mengerti" }, + "perps_trading_campaign": { + "title": "Perps Trading Competition", + "stats_title": "Stats", + "performance_title": "Performance", + "label_rank": "Rank", + "label_your_rank": "Your rank", + "label_pnl": "PnL", + "label_volume": "Volume", + "label_notional_volume": "Notional volume", + "label_margin": "Margin", + "label_margin_deployed": "Margin deployed", + "pending": "Pending", + "qualified": "Qualified", + "open_position_cta": "Open Position", + "leaderboard_title": "Leaderboard", + "leaderboard_total_participants": "{{count}} participants", + "last_updated": "Last updated: {{time}}", + "leaderboard_error_loading": "Failed to load leaderboard", + "leaderboard_error_loading_description": "Something went wrong while loading the leaderboard. Please try again.", + "leaderboard_not_yet_computed": "Leaderboard hasn't been computed yet. Check back soon.", + "leaderboard_powered_by_prefix": "Powered by ", + "leaderboard_hypertracker_brand": "HyperTracker", + "prize_pool_title": "Prize pool", + "prize_pool_current_label": "Current", + "prize_pool_next_label": "Next", + "prize_pool_volume_subtext": "{{current}} of {{target}} volume", + "prize_pool_max_tier_subtext": "{{maxThreshold}}+ volume — all milestones reached", + "prize_pool_max_badge": "Max", + "prize_pool_error_title": "Failed to load prize pool", + "prize_pool_error_description": "There was an error loading the prize pool. Please try again.", + "prize_pool_retry_button": "Retry", + "stats_notional_volume_threshold": "${{amount}} required", + "stats_qualified_title": "You're qualified", + "stats_qualified_description": "Keep trading to improve your PnL and climb the leaderboard before the competition ends.", + "stats_qualify_for_rank_title": "Qualify for this rank", + "stats_qualify_for_rank_description": "Trade {{notionalRemaining}} more in notional volume to become eligible.", + "stats_error_title": "Unable to load stats", + "stats_error_description": "We had a problem loading your stats. Please try again.", + "stats_retry": "Retry" + }, "campaigns_preview": { "title": "Kampanye", "coming_soon": "Segera hadir", @@ -8661,7 +8788,9 @@ }, "trending": { "title": "Jelajahi", - "trending_tokens": "Tren", + "crypto_movers": "Crypto movers", + "perps_movers": "Perps movers", + "trending_tokens": "Sedang tren", "stocks": "Saham", "price_change": "Perubahan harga", "all_networks": "Semua jaringan", @@ -8672,6 +8801,17 @@ "1_hour": "1 jam", "5_minutes": "5 menit", "networks": "Jaringan", + "ecosystems": "Ecosystems", + "ecosystems_subtitle": "Explore dApps, tokens, and NFTs across chains on MetaMask Portfolio", + "all_sports": "Popular sports", + "basketball": "Basketball", + "football": "Football", + "soccer": "Soccer", + "tennis": "Tennis", + "f1": "F1", + "golf": "Golf", + "all_sports_no_markets": "No open markets in this category yet. Check back soon or pick another sport above.", + "load_more": "Load more", "sort_by": "Urutkan sesuai", "volume": "Volume", "market_cap": "Kap pasar", @@ -8681,8 +8821,17 @@ "search_placeholder": "Cari token, situs, URL", "cancel": "Batal", "perps": "Perps", + "rwa_perps_section": "Pasar", + "macro_stocks_commodity_perps": "Stocks & commodities", + "macro_pill_stocks": "Saham", + "macro_pill_commodities": "Komoditas", + "rwa_pill_commodities": "Komoditas", + "rwa_pill_stocks": "Saham", + "rwa_pill_forex": "Forex", + "crypto_perps_section": "Perp", "predictions": "Prediksi", "no_results": "Hasil tidak ditemukan", + "popular": "Populer", "sites": "Situs", "popular_sites": "Situs populer", "search_sites": "Cari situs", @@ -8698,6 +8847,14 @@ "empty_search_result_state": { "title": "Tidak ada token yang ditemukan", "description": "Kami tidak dapat menemukan token ini" + }, + "tabs": { + "now": "Sekarang", + "macro": "Makro", + "rwas": "RWAs", + "crypto": "Kripto", + "sports": "Olahraga", + "dapps": "Situs" } }, "ota_update_modal": { @@ -8715,7 +8872,9 @@ }, "common": { "cancel": "Batal", - "continue": "Lanjutkan" + "continue": "Lanjutkan", + "learn_more": "Pelajari selengkapnya", + "try_again": "Coba lagi" }, "connecting": { "title": "Menghubungkan {{device}} Anda...", @@ -8761,6 +8920,7 @@ "bluetooth_off": "Aktifkan Bluetooth untuk terhubung ke perangkat Anda", "bluetooth_scan_failed": "Gagal memindai perangkat. Coba lagi", "bluetooth_connection_failed": "Koneksi ke perangkat Anda gagal. Coba lagi", + "camera_permission_denied": "Camera permission is required to scan QR codes. Please enable it in your device settings", "not_supported": "Operasi ini tidak didukung", "unknown_error": "Pastikan {{device}} Anda telah diatur dengan Frasa Pemulihan Rahasia atau passphrase untuk akun ini" }, @@ -8784,8 +8944,35 @@ "location_permission_denied": "Izin Lokasi Diperlukan", "nearby_devices_permission_denied": "Izin Perangkat Terdekat Diperlukan", "scan_failed": "Pemindaian Gagal", + "camera_permission_denied": "Camera Permission Required", "something_went_wrong": "Terjadi kesalahan" }, + "qr_scan_errors": { + "non_ur_qr_scanned": { + "pair": { + "title": "Scan wallet sync QR code", + "body": "Go back to your hardware wallet and find the option to share or export your accounts, choose MetaMask, then scan the QR code." + }, + "sign": { + "title": "Scan signature QR code", + "body": "Go back to your hardware wallet and approve the transaction, then scan the QR code it shows." + } + }, + "wrong_ur_type": { + "pair": { + "title": "Wrong QR code", + "body": "Go back to your hardware wallet, choose MetaMask as the wallet type, then generate and scan a new QR code." + }, + "sign": { + "title": "Wrong QR code", + "body": "Go back to your hardware wallet and approve the transaction, then scan the QR code it shows." + } + }, + "ur_decode_error": { + "title": "Couldn't read QR code", + "body": "Make sure the QR code is fully visible and well-lit, and try again. If the issue continues, you may need to update your hardware wallet's firmware." + } + }, "device_selection": { "title": "Pilih {{device}}", "scanning": "Memindai perangkat...", @@ -8803,6 +8990,14 @@ "cash": "Dana", "cash_empty_description": "Anda belum memiliki mUSD. Konversikan stablecoin ke mUSD dari bagian Dana di halaman utama.", "cash_empty_description_network_filter": "Tidak ada mUSD di jaringan ini. Ganti jaringan untuk melihat mUSD milik Anda.", + "cash_empty_state": { + "get_started": "Mulai", + "earn_apy": "Earn {{percentage}}% APY" + }, + "cash_filled_state": { + "add": "Tambah", + "apy": "APY {{percentage}}%" + }, "tokens": "Token", "perpetuals": "Abadi", "predictions": "Prediksi", @@ -8818,7 +9013,7 @@ }, "defi": "DeFi", "nfts": "NFT", - "trending_tokens": "Tren", + "trending_tokens": "Sedang tren", "trending_perpetuals": "Perpetual yang tren", "trending_predictions": "Prediksi yang tren", "import_nfts": "Impor NFT", @@ -8832,5 +9027,8 @@ "unable_to_load": "Tidak dapat memuat {{section}}", "retry": "Coba lagi" } + }, + "sites": { + "popular": "Populer" } } diff --git a/locales/languages/ja.json b/locales/languages/ja.json index 977bcaacf60..9950f780af3 100644 --- a/locales/languages/ja.json +++ b/locales/languages/ja.json @@ -634,7 +634,8 @@ "trade": "取引", "settings": "設定", "rewards": "報酬", - "trending": "閲覧" + "trending": "閲覧", + "money": "マネー" }, "drawer": { "send_button": "送信", @@ -2446,6 +2447,12 @@ } } }, + "payment": { + "change_payment_method": "Change Payment Method", + "add_funds": "資金を追加", + "add": "追加", + "predict_balance": "Predict balance" + }, "order": { "available": "使用可能", "cashed_out": "キャッシュアウト済み", @@ -3314,6 +3321,8 @@ "invalid_number_range": "無効な数字です。1と%{maxSafeChainId}の間に数字を入力してください。", "hide_zero_balance_tokens_title": "残高のないトークンを非表示", "hide_zero_balance_tokens_desc": "トークンリストに残高がないトークンが表示されないようにします。", + "haptic_feedback_title": "Haptic feedback", + "haptic_feedback_desc": "Feel subtle vibrations for confirmations, errors, and interactions. Disable to reduce haptic stimuli.", "token_detection_title": "トークンの自動検出", "token_detection_description": "サードパーティーAPIを使用して、ウォレットに送られた新しいトークンを検出・表示します。アプリがこれらのサービスからデータを取得しないようにするには、オフにしてください。", "theme_button_text": "テーマを変更", @@ -3457,7 +3466,7 @@ "title": "Headless buy", "loading": "Preparing the buy flow…", "no_session": "Headless session is no longer active.", - "cancel": "Cancel" + "cancel": "キャンセル" } }, "request_feature": "機能のリクエスト", @@ -3572,6 +3581,39 @@ "title": "カード", "reset_onboarding_description": "オンボーディングフローをやり直すには、カードのオンボーディングステータスをリセットしてください。", "reset_onboarding_button": "オンボーディングステータスのリセット" + }, + "haptics": { + "title": "Haptics", + "description": "Preview haptic feedback on a physical device. This bypasses in-app reduced-haptics and the remote kill switch so you can evaluate each pattern. Simulators often do not play haptics.", + "catalog_impacts_heading": "Catalog impact moments", + "notifications_heading": "Notification patterns", + "selection_heading": "Selection", + "notification_success_button": "Notification — Success", + "notification_error_button": "Notification — Error", + "notification_warning_button": "Notification — Warning", + "selection_button": "Selection", + "raw_impacts_heading": "Raw Expo impact styles", + "raw_impacts_desc": "Direct expo-haptics impact styles for comparison with the mapped catalog moments above.", + "impacts": { + "quick_amount_selection": "Impact — Quick amount selection", + "slider_tick": "Impact — Slider tick", + "edge_gesture_engage": "Impact — Edge gesture engage", + "page_navigation": "Impact — Page navigation", + "slider_grip": "Impact — Slider grip", + "tab_change": "Impact — Tab change", + "primary_cta": "Impact — Primary CTA", + "pull_to_refresh_engage": "Impact — Pull to refresh (engage)", + "pull_to_refresh": "Impact — Pull to refresh (release)", + "chart_crosshair": "Impact — Chart crosshair", + "follow_toggle": "Impact — Follow toggle" + }, + "raw_styles": { + "light": "Raw impact — Light", + "medium": "Raw impact — Medium", + "heavy": "Raw impact — Heavy", + "rigid": "Raw impact — Rigid", + "soft": "Raw impact — Soft" + } } }, "feature_flag_override": { @@ -6482,7 +6524,7 @@ "onboarding": { "step_progress": "ステップ{{current}}/{{total}}", "title": "お金を追加", - "description": "mUSDを購入して、今すぐAPYの獲得を始めましょう", + "description": "Fund your account and start earning APY.", "add": "追加", "step2_title": "MetaMaskカードを取得", "step2_description": "Mastercardの取扱店であればどこでも、収益を得ると同時にマネー残高を使用できます。", @@ -6499,7 +6541,8 @@ "earnings": { "title": "収益", "lifetime": "累積報酬額", - "projected": "予想収益" + "projected": "予想収益", + "info_label": "Earnings info" }, "how_it_works": { "title": "報酬獲得の仕組み", @@ -6553,6 +6596,35 @@ "receive_external": "Receive from external wallet", "coming_soon": "近日追加予定" }, + "more_sheet": { + "title": "さらに表示", + "how_it_works": "報酬獲得の仕組み", + "what_you_get": "得られるもの", + "contact_support": "サポートへのお問い合わせ" + }, + "transfer_sheet": { + "title": "Transfer money", + "between_accounts": "Between accounts", + "perps_account": "Perps account", + "predictions_account": "Predictions account", + "send_external": "Send to external address", + "withdraw_to_bank": "Withdraw to bank" + }, + "apy_tooltip": { + "title": "Annual Percentage Yield (APY)", + "paragraph_1": "Your Money account earns up to {{percentage}}% automatically.", + "paragraph_2": "The rate shown is an estimated Annual Percentage Yield (APY) based on current market conditions. APY is variable and may change due to various factors. The rate is generated by third-party DeFi platforms that deploy funds in blockchain protocols.", + "paragraph_3": "When you link your MetaMask Card, purchases will be deducted from this balance.", + "learn_more": "詳細" + }, + "earnings_tooltip": { + "title": "収益", + "lifetime_heading": "累積報酬額", + "lifetime_body": "The total yield you've earned since opening your Money account.", + "projected_heading": "予想収益", + "projected_body": "現在の残高とレートに基づき1年間でいくら稼げるかの予想です。", + "disclaimer": "Projection assumes {{percentage}}% Annual Percentage Yield (APY) remains unchanged for 1 year. APY is variable and may change due to various factors. No guarantee of return." + }, "activity": { "title": "アクティビティ", "view_all": "すべて表示", @@ -6917,7 +6989,9 @@ "perps_deposit": "資金を追加", "perps_withdraw": "出金", "predict_deposit": "予測資金を追加", - "predict_withdraw": "出金" + "predict_withdraw": "出金", + "money_account_add_money": "お金を追加", + "money_account_transfer_money": "Transfer money" }, "sub_title": { "permit": "このサイトがトークンの使用許可を求めています。", @@ -6937,7 +7011,7 @@ "transaction_fee": "HyperCore (パーペチュアル取引で使用されるネットワーク) 上でトークンをUSDCにスワップします。スワッププロバイダーは手数料を請求する場合がありますが、MetaMaskは無料です。" }, "predict_deposit": { - "transaction_fee": "Polygon (予測で使用されるネットワーク) 上でトークンをUSDCにスワップします。スワッププロバイダーは手数料を請求する場合がありますが、MetaMaskは無料です。" + "transaction_fee": "We'll swap your tokens for pUSD on Polygon, the network used by Predictions. Swap providers may charge a fee, but MetaMask won't." }, "perps_withdraw": { "transaction_fee": "MetaMaskがユーザーに代わって希望のトークンをスワップします。mUSDへのスワップ時にMetaMaskの手数料は発生しません。" @@ -7033,7 +7107,8 @@ "custom_amount": { "buy_button": "仮想通貨を購入", "buy_predict": "予測を利用するには、ウォレットに資金を追加してください。", - "buy_perps": "パーペチュアルを利用するには、ウォレットに資金を追加してください。" + "buy_perps": "パーペチュアルを利用するには、ウォレットに資金を追加してください。", + "projected_five_year_balance": "Projected 5-year balance:" }, "unlimited": "無制限", "all": "すべて", @@ -7113,6 +7188,7 @@ "receive_at": "受取先:", "recipient": "受取人", "select_recipient": "受取人を選択してください", + "select_account": "アカウントの選択", "external_account": "外部アカウント", "error_banner_description": "この取引ルートは現在使用できません。金額、ネットワーク、またはトークンを変更してみてください。最善のオプションを探します。", "stock_token_error_banner_description": "この取引ルートは現在使用できません。金額、ネットワーク、またはトークンを変更してみてください。最適なオプションを検索します。", @@ -7791,6 +7867,7 @@ "enable_card_button_label": "カードを有効にする", "enable_assets_button_label": "アセットを有効にする", "spending_limit_warning": "使用上限に近づいています。拒否されないように更新してください。", + "spending_limit_available": "使用可能", "logout": "ログアウト", "contact_support": "サポートへのお問い合わせ", "logout_confirmation_title": "カードからログアウトしますか?", @@ -7925,7 +8002,12 @@ "withdrawal_success": "出金が完了しました", "withdrawal_failed": "出金に失敗しました。もう一度お試しください。", "no_cashback": "利用可能なキャッシュバックはありません", - "loading_error": "キャッシュバックを読み込めませんでした。もう一度お試しください。" + "loading_error": "キャッシュバックを読み込めませんでした。もう一度お試しください。", + "funding_required": { + "title": "Set up Linea funding", + "description": "You need at least one approved funding source on Linea before redeeming cashback.", + "confirm_button_label": "Set up funding" + } }, "change_asset": { "title": "トークンとネットワークの変更", @@ -8030,7 +8112,8 @@ "title": "支払方法を選択", "title_receive": "トークンの受取を選択してください", "no_gas": "ガスの支払いに使えるネイティブ残高がありません", - "not_supported": "未対応" + "not_supported": "未対応", + "crypto": "仮想通貨" }, "connection_removed_modal": { "title": "接続が削除されました", @@ -8365,6 +8448,8 @@ "ends_date": "{{date}}終了", "ended_date": "{{date}}に終了", "pill_up_next": "近日追加予定", + "up_next": "次", + "notify_me": "通知を受ける", "pill_active": "ライブ", "pill_complete": "完了", "enter": "応募", @@ -8378,7 +8463,9 @@ "geo_locked_toast_title": "お客様の地域では利用できません", "geo_locked_toast_description": "このキャンペーンは、お客様の場所で利用できません。新しいキャンペーンがないか、後日また確認してください。", "geo_locked_cta": "資格を確認", - "geo_loading": "地域を確認中…" + "geo_loading": "地域を確認中…", + "remind_me_success_toast": "We'll let you know when this campaign starts.", + "remind_me_save_error": "Could not save your reminder. Please try again." }, "campaign_mechanics": { "title": "仕組み" @@ -8507,6 +8594,46 @@ "cancel": "キャンセル", "confirm": "理解しています" }, + "perps_trading_campaign": { + "title": "Perps Trading Competition", + "stats_title": "Stats", + "performance_title": "Performance", + "label_rank": "Rank", + "label_your_rank": "Your rank", + "label_pnl": "PnL", + "label_volume": "Volume", + "label_notional_volume": "Notional volume", + "label_margin": "Margin", + "label_margin_deployed": "Margin deployed", + "pending": "Pending", + "qualified": "Qualified", + "open_position_cta": "Open Position", + "leaderboard_title": "Leaderboard", + "leaderboard_total_participants": "{{count}} participants", + "last_updated": "Last updated: {{time}}", + "leaderboard_error_loading": "Failed to load leaderboard", + "leaderboard_error_loading_description": "Something went wrong while loading the leaderboard. Please try again.", + "leaderboard_not_yet_computed": "Leaderboard hasn't been computed yet. Check back soon.", + "leaderboard_powered_by_prefix": "Powered by ", + "leaderboard_hypertracker_brand": "HyperTracker", + "prize_pool_title": "Prize pool", + "prize_pool_current_label": "Current", + "prize_pool_next_label": "Next", + "prize_pool_volume_subtext": "{{current}} of {{target}} volume", + "prize_pool_max_tier_subtext": "{{maxThreshold}}+ volume — all milestones reached", + "prize_pool_max_badge": "Max", + "prize_pool_error_title": "Failed to load prize pool", + "prize_pool_error_description": "There was an error loading the prize pool. Please try again.", + "prize_pool_retry_button": "Retry", + "stats_notional_volume_threshold": "${{amount}} required", + "stats_qualified_title": "You're qualified", + "stats_qualified_description": "Keep trading to improve your PnL and climb the leaderboard before the competition ends.", + "stats_qualify_for_rank_title": "Qualify for this rank", + "stats_qualify_for_rank_description": "Trade {{notionalRemaining}} more in notional volume to become eligible.", + "stats_error_title": "Unable to load stats", + "stats_error_description": "We had a problem loading your stats. Please try again.", + "stats_retry": "Retry" + }, "campaigns_preview": { "title": "キャンペーン", "coming_soon": "近日追加予定", @@ -8661,7 +8788,9 @@ }, "trending": { "title": "閲覧", - "trending_tokens": "トレンド", + "crypto_movers": "Crypto movers", + "perps_movers": "Perps movers", + "trending_tokens": "人気上昇中", "stocks": "株式", "price_change": "価格変動", "all_networks": "すべてのネットワーク", @@ -8672,6 +8801,17 @@ "1_hour": "1時間", "5_minutes": "5分", "networks": "ネットワーク", + "ecosystems": "Ecosystems", + "ecosystems_subtitle": "Explore dApps, tokens, and NFTs across chains on MetaMask Portfolio", + "all_sports": "Popular sports", + "basketball": "Basketball", + "football": "Football", + "soccer": "Soccer", + "tennis": "Tennis", + "f1": "F1", + "golf": "Golf", + "all_sports_no_markets": "No open markets in this category yet. Check back soon or pick another sport above.", + "load_more": "Load more", "sort_by": "並べ替え基準", "volume": "取引量", "market_cap": "時価総額", @@ -8681,8 +8821,17 @@ "search_placeholder": "トークン、サイト、URLを検索", "cancel": "キャンセル", "perps": "パーペチュアル", + "rwa_perps_section": "市場", + "macro_stocks_commodity_perps": "Stocks & commodities", + "macro_pill_stocks": "株式", + "macro_pill_commodities": "商品", + "rwa_pill_commodities": "商品", + "rwa_pill_stocks": "株式", + "rwa_pill_forex": "FX", + "crypto_perps_section": "パーペチュアル", "predictions": "予測", "no_results": "結果が見つかりませんでした", + "popular": "人気", "sites": "サイト", "popular_sites": "人気のサイト", "search_sites": "サイトを検索", @@ -8698,6 +8847,14 @@ "empty_search_result_state": { "title": "トークンが見つかりませんでした", "description": "このトークンを見つけることができませんでした" + }, + "tabs": { + "now": "現在", + "macro": "マクロ", + "rwas": "RWAs", + "crypto": "仮想通貨", + "sports": "スポーツ", + "dapps": "サイト" } }, "ota_update_modal": { @@ -8715,7 +8872,9 @@ }, "common": { "cancel": "キャンセル", - "continue": "続行" + "continue": "続行", + "learn_more": "詳細", + "try_again": "もう一度お試しください" }, "connecting": { "title": "{{device}}を接続しています...", @@ -8761,6 +8920,7 @@ "bluetooth_off": "デバイスに接続するには、Bluetoothをオンにしてください", "bluetooth_scan_failed": "デバイスを検索できませんでした。もう一度お試しください。", "bluetooth_connection_failed": "デバイスへの接続に失敗しました。もう一度お試しください", + "camera_permission_denied": "Camera permission is required to scan QR codes. Please enable it in your device settings", "not_supported": "この操作はサポートされていません。", "unknown_error": "{{device}}がこのアカウント用のシークレットリカバリーフレーズまたはパスフレーズを使ってセットアップされていることを確認してください。" }, @@ -8784,8 +8944,35 @@ "location_permission_denied": "位置情報へのアクセス許可が必要です", "nearby_devices_permission_denied": "付近のデバイスへのアクセス許可が必要です", "scan_failed": "検索に失敗しました", + "camera_permission_denied": "Camera Permission Required", "something_went_wrong": "問題が発生しました" }, + "qr_scan_errors": { + "non_ur_qr_scanned": { + "pair": { + "title": "Scan wallet sync QR code", + "body": "Go back to your hardware wallet and find the option to share or export your accounts, choose MetaMask, then scan the QR code." + }, + "sign": { + "title": "Scan signature QR code", + "body": "Go back to your hardware wallet and approve the transaction, then scan the QR code it shows." + } + }, + "wrong_ur_type": { + "pair": { + "title": "Wrong QR code", + "body": "Go back to your hardware wallet, choose MetaMask as the wallet type, then generate and scan a new QR code." + }, + "sign": { + "title": "Wrong QR code", + "body": "Go back to your hardware wallet and approve the transaction, then scan the QR code it shows." + } + }, + "ur_decode_error": { + "title": "Couldn't read QR code", + "body": "Make sure the QR code is fully visible and well-lit, and try again. If the issue continues, you may need to update your hardware wallet's firmware." + } + }, "device_selection": { "title": "{{device}}の選択", "scanning": "デバイスを検索中...", @@ -8803,6 +8990,14 @@ "cash": "マネー", "cash_empty_description": "まだmUSDがありません。ホームページの「マネー」セクションでステーブルコインをmUSDに換金してください。", "cash_empty_description_network_filter": "このネットワークにはmUSDがありません。ネットワークを切り替えてお持ちのmUSDをご確認ください。", + "cash_empty_state": { + "get_started": "開始", + "earn_apy": "Earn {{percentage}}% APY" + }, + "cash_filled_state": { + "add": "追加", + "apy": "年換算利率 (APY) {{percentage}}%" + }, "tokens": "トークン", "perpetuals": "パーペチュアル", "predictions": "予測", @@ -8818,7 +9013,7 @@ }, "defi": "DeFi", "nfts": "NFT", - "trending_tokens": "トレンド", + "trending_tokens": "人気上昇中", "trending_perpetuals": "人気のパーペチュアル", "trending_predictions": "人気の予測", "import_nfts": "NFTをインポート", @@ -8832,5 +9027,8 @@ "unable_to_load": "{{section}}を読み込めませんでした", "retry": "再試行" } + }, + "sites": { + "popular": "人気" } } diff --git a/locales/languages/ko.json b/locales/languages/ko.json index d23ddf36792..7406260871f 100644 --- a/locales/languages/ko.json +++ b/locales/languages/ko.json @@ -634,7 +634,8 @@ "trade": "거래하기", "settings": "설정", "rewards": "보상", - "trending": "둘러보기" + "trending": "둘러보기", + "money": "머니" }, "drawer": { "send_button": "보내기", @@ -2446,6 +2447,12 @@ } } }, + "payment": { + "change_payment_method": "Change Payment Method", + "add_funds": "자금 추가", + "add": "추가", + "predict_balance": "Predict balance" + }, "order": { "available": "사용 가능", "cashed_out": "출금 완료", @@ -3314,6 +3321,8 @@ "invalid_number_range": "잘못된 숫자입니다. 1과 %{maxSafeChainId} 사이에 숫자를 입력하세요", "hide_zero_balance_tokens_title": "잔고 없는 토큰 숨기기", "hide_zero_balance_tokens_desc": "토큰 목록에서 잔고가 없는 토큰을 보이지 않게 합니다.", + "haptic_feedback_title": "Haptic feedback", + "haptic_feedback_desc": "Feel subtle vibrations for confirmations, errors, and interactions. Disable to reduce haptic stimuli.", "token_detection_title": "토큰 자동 감지", "token_detection_description": "MetaMask는 지갑에 보내진 새로운 토큰을 감지해 표시하기 위해 타사 API를 사용합니다. 이 서비스로부터 데이터를 가져오기를 원치 않으시면 해당 기능을 끄세요.", "theme_button_text": "테마 변경", @@ -3457,7 +3466,7 @@ "title": "Headless buy", "loading": "Preparing the buy flow…", "no_session": "Headless session is no longer active.", - "cancel": "Cancel" + "cancel": "취소" } }, "request_feature": "기능 요청", @@ -3572,6 +3581,39 @@ "title": "카드", "reset_onboarding_description": "온보딩 흐름을 처음부터 다시 시작하려면 카드 온보딩 상태를 초기화하세요.", "reset_onboarding_button": "온보딩 상태 초기화" + }, + "haptics": { + "title": "Haptics", + "description": "Preview haptic feedback on a physical device. This bypasses in-app reduced-haptics and the remote kill switch so you can evaluate each pattern. Simulators often do not play haptics.", + "catalog_impacts_heading": "Catalog impact moments", + "notifications_heading": "Notification patterns", + "selection_heading": "Selection", + "notification_success_button": "Notification — Success", + "notification_error_button": "Notification — Error", + "notification_warning_button": "Notification — Warning", + "selection_button": "Selection", + "raw_impacts_heading": "Raw Expo impact styles", + "raw_impacts_desc": "Direct expo-haptics impact styles for comparison with the mapped catalog moments above.", + "impacts": { + "quick_amount_selection": "Impact — Quick amount selection", + "slider_tick": "Impact — Slider tick", + "edge_gesture_engage": "Impact — Edge gesture engage", + "page_navigation": "Impact — Page navigation", + "slider_grip": "Impact — Slider grip", + "tab_change": "Impact — Tab change", + "primary_cta": "Impact — Primary CTA", + "pull_to_refresh_engage": "Impact — Pull to refresh (engage)", + "pull_to_refresh": "Impact — Pull to refresh (release)", + "chart_crosshair": "Impact — Chart crosshair", + "follow_toggle": "Impact — Follow toggle" + }, + "raw_styles": { + "light": "Raw impact — Light", + "medium": "Raw impact — Medium", + "heavy": "Raw impact — Heavy", + "rigid": "Raw impact — Rigid", + "soft": "Raw impact — Soft" + } } }, "feature_flag_override": { @@ -6482,7 +6524,7 @@ "onboarding": { "step_progress": "{{current}} 단계/{{total}} 단계", "title": "자금 추가", - "description": "mUSD를 구매하여 오늘부터 APY를 버세요.", + "description": "Fund your account and start earning APY.", "add": "추가", "step2_title": "MetaMask 카드 받기", "step2_description": "Mastercard가 허용되는 모든 곳에서 수익이 쌓이는 동안 Money 잔액을 사용하세요.", @@ -6499,7 +6541,8 @@ "earnings": { "title": "수익", "lifetime": "누적 수익", - "projected": "예상 수익" + "projected": "예상 수익", + "info_label": "Earnings info" }, "how_it_works": { "title": "작동 방식", @@ -6553,6 +6596,35 @@ "receive_external": "Receive from external wallet", "coming_soon": "곧 추가 예정" }, + "more_sheet": { + "title": "더 보기", + "how_it_works": "작동 방식", + "what_you_get": "혜택", + "contact_support": "지원팀에 문의" + }, + "transfer_sheet": { + "title": "Transfer money", + "between_accounts": "Between accounts", + "perps_account": "Perps account", + "predictions_account": "Predictions account", + "send_external": "Send to external address", + "withdraw_to_bank": "Withdraw to bank" + }, + "apy_tooltip": { + "title": "Annual Percentage Yield (APY)", + "paragraph_1": "Your Money account earns up to {{percentage}}% automatically.", + "paragraph_2": "The rate shown is an estimated Annual Percentage Yield (APY) based on current market conditions. APY is variable and may change due to various factors. The rate is generated by third-party DeFi platforms that deploy funds in blockchain protocols.", + "paragraph_3": "When you link your MetaMask Card, purchases will be deducted from this balance.", + "learn_more": "더 보기" + }, + "earnings_tooltip": { + "title": "수익", + "lifetime_heading": "누적 수익", + "lifetime_body": "The total yield you've earned since opening your Money account.", + "projected_heading": "예상 수익", + "projected_body": "현재 잔액과 금리를 기준으로 1년 동안 받을 수 있는 예상 금액입니다.", + "disclaimer": "Projection assumes {{percentage}}% Annual Percentage Yield (APY) remains unchanged for 1 year. APY is variable and may change due to various factors. No guarantee of return." + }, "activity": { "title": "활동", "view_all": "모두 보기", @@ -6917,7 +6989,9 @@ "perps_deposit": "자금 추가", "perps_withdraw": "출금", "predict_deposit": "예측 자금 추가", - "predict_withdraw": "출금" + "predict_withdraw": "출금", + "money_account_add_money": "자금 추가", + "money_account_transfer_money": "Transfer money" }, "sub_title": { "permit": "이 사이트에서 토큰 사용 권한을 요청합니다.", @@ -6937,7 +7011,7 @@ "transaction_fee": "무기한 선물 거래 시 사용되는 네트워크인 HyperCore에서 토큰을 USDC로 스왑합니다. 스왑 제공업체가 수수료를 부과할 수 있지만 MetaMask에서 부과하는 수수료는 없습니다." }, "predict_deposit": { - "transaction_fee": "예측에서 사용하는 네트워크인 Polygon에서 토큰을 USDC.e로 스왑합니다. 스왑 제공업체가 수수료를 부과할 수 있지만 MetaMask에서 부과하는 수수료는 없습니다." + "transaction_fee": "We'll swap your tokens for pUSD on Polygon, the network used by Predictions. Swap providers may charge a fee, but MetaMask won't." }, "perps_withdraw": { "transaction_fee": "MetaMask가 원하는 토큰으로 스왑해 드립니다. mUSD로 스왑할 때 MetaMask 수수료는 적용되지 않습니다." @@ -7033,7 +7107,8 @@ "custom_amount": { "buy_button": "암호화폐 구매", "buy_predict": "예측 기능을 사용하려면 지갑에 자금을 추가하세요.", - "buy_perps": "무기한 선물을 사용하려면 지갑에 자금을 추가하세요." + "buy_perps": "무기한 선물을 사용하려면 지갑에 자금을 추가하세요.", + "projected_five_year_balance": "Projected 5-year balance:" }, "unlimited": "무제한", "all": "모두", @@ -7113,6 +7188,7 @@ "receive_at": "수령 주소:", "recipient": "받는 사람", "select_recipient": "수신자 선택", + "select_account": "계정 선택", "external_account": "외부 계정", "error_banner_description": "현재 이 거래 경로를 이용할 수 없습니다. 금액이나 네트워크, 토큰을 변경해 보세요. 최적의 옵션을 찾아드리겠습니다.", "stock_token_error_banner_description": "현재 이 거래 경로를 이용할 수 없습니다. 금액이나 네트워크, 토큰을 변경해 보세요. 최적의 옵션을 찾아드리겠습니다.\n\nOndo 토큰화 주식을 거래하려는 경우 미국, EU, 영국, 브라질 등 지역 제한이 적용될 수 있습니다.", @@ -7791,6 +7867,7 @@ "enable_card_button_label": "카드 활성화", "enable_assets_button_label": "자산 활성화", "spending_limit_warning": "지출 한도가 거의 찼습니다. 결제가 거부되지 않도록 정보를 업데이트하세요.", + "spending_limit_available": "사용 가능", "logout": "로그아웃", "contact_support": "지원팀에 문의", "logout_confirmation_title": "카드에서 로그아웃하시겠습니까?", @@ -7925,7 +8002,12 @@ "withdrawal_success": "성공적으로 출금 완료", "withdrawal_failed": "출금에 실패했습니다. 다시 시도하세요.", "no_cashback": "캐시백을 사용할 수 없음", - "loading_error": "캐시백을 불러오지 못했습니다. 다시 시도하세요." + "loading_error": "캐시백을 불러오지 못했습니다. 다시 시도하세요.", + "funding_required": { + "title": "Set up Linea funding", + "description": "You need at least one approved funding source on Linea before redeeming cashback.", + "confirm_button_label": "Set up funding" + } }, "change_asset": { "title": "토큰 및 네트워크 변경", @@ -8030,7 +8112,8 @@ "title": "결제 방법 선택", "title_receive": "토큰 받기 선택", "no_gas": "가스비로 사용할 네이티브 토큰 잔액 없음", - "not_supported": "지원되지 않음" + "not_supported": "지원되지 않음", + "crypto": "암호화폐" }, "connection_removed_modal": { "title": "연결 제거 완료", @@ -8365,6 +8448,8 @@ "ends_date": "종료일: {{date}}", "ended_date": "{{date}}에 종료됨", "pill_up_next": "곧 추가 예정", + "up_next": "다음 일정", + "notify_me": "알림 받기", "pill_active": "진행 중", "pill_complete": "완료", "enter": "참여", @@ -8378,7 +8463,9 @@ "geo_locked_toast_title": "회원님의 지역에서 사용할 수 없습니다", "geo_locked_toast_description": "이 캠페인은 해당 지역에서 이용할 수 없습니다. 새 캠페인이 있는지 나중에 다시 확인해 주세요.", "geo_locked_cta": "자격 확인", - "geo_loading": "지역 확인 중..." + "geo_loading": "지역 확인 중...", + "remind_me_success_toast": "We'll let you know when this campaign starts.", + "remind_me_save_error": "Could not save your reminder. Please try again." }, "campaign_mechanics": { "title": "운영 방식" @@ -8507,6 +8594,46 @@ "cancel": "취소", "confirm": "견적은 다음 기간 전에 만료됨을" }, + "perps_trading_campaign": { + "title": "Perps Trading Competition", + "stats_title": "Stats", + "performance_title": "Performance", + "label_rank": "Rank", + "label_your_rank": "Your rank", + "label_pnl": "PnL", + "label_volume": "Volume", + "label_notional_volume": "Notional volume", + "label_margin": "Margin", + "label_margin_deployed": "Margin deployed", + "pending": "Pending", + "qualified": "Qualified", + "open_position_cta": "Open Position", + "leaderboard_title": "Leaderboard", + "leaderboard_total_participants": "{{count}} participants", + "last_updated": "Last updated: {{time}}", + "leaderboard_error_loading": "Failed to load leaderboard", + "leaderboard_error_loading_description": "Something went wrong while loading the leaderboard. Please try again.", + "leaderboard_not_yet_computed": "Leaderboard hasn't been computed yet. Check back soon.", + "leaderboard_powered_by_prefix": "Powered by ", + "leaderboard_hypertracker_brand": "HyperTracker", + "prize_pool_title": "Prize pool", + "prize_pool_current_label": "Current", + "prize_pool_next_label": "Next", + "prize_pool_volume_subtext": "{{current}} of {{target}} volume", + "prize_pool_max_tier_subtext": "{{maxThreshold}}+ volume — all milestones reached", + "prize_pool_max_badge": "Max", + "prize_pool_error_title": "Failed to load prize pool", + "prize_pool_error_description": "There was an error loading the prize pool. Please try again.", + "prize_pool_retry_button": "Retry", + "stats_notional_volume_threshold": "${{amount}} required", + "stats_qualified_title": "You're qualified", + "stats_qualified_description": "Keep trading to improve your PnL and climb the leaderboard before the competition ends.", + "stats_qualify_for_rank_title": "Qualify for this rank", + "stats_qualify_for_rank_description": "Trade {{notionalRemaining}} more in notional volume to become eligible.", + "stats_error_title": "Unable to load stats", + "stats_error_description": "We had a problem loading your stats. Please try again.", + "stats_retry": "Retry" + }, "campaigns_preview": { "title": "캠페인", "coming_soon": "곧 추가 예정", @@ -8661,7 +8788,9 @@ }, "trending": { "title": "둘러보기", - "trending_tokens": "트렌드", + "crypto_movers": "Crypto movers", + "perps_movers": "Perps movers", + "trending_tokens": "인기", "stocks": "주식", "price_change": "가격 변동", "all_networks": "모든 네트워크", @@ -8672,6 +8801,17 @@ "1_hour": "1시간", "5_minutes": "5분", "networks": "네트워크", + "ecosystems": "Ecosystems", + "ecosystems_subtitle": "Explore dApps, tokens, and NFTs across chains on MetaMask Portfolio", + "all_sports": "Popular sports", + "basketball": "Basketball", + "football": "Football", + "soccer": "Soccer", + "tennis": "Tennis", + "f1": "F1", + "golf": "Golf", + "all_sports_no_markets": "No open markets in this category yet. Check back soon or pick another sport above.", + "load_more": "Load more", "sort_by": "정렬 기준", "volume": "거래량", "market_cap": "시가총액", @@ -8681,8 +8821,17 @@ "search_placeholder": "토큰, 사이트 및 URL 검색", "cancel": "취소", "perps": "무기한 선물", + "rwa_perps_section": "시장", + "macro_stocks_commodity_perps": "Stocks & commodities", + "macro_pill_stocks": "주식", + "macro_pill_commodities": "상품", + "rwa_pill_commodities": "상품", + "rwa_pill_stocks": "주식", + "rwa_pill_forex": "외환", + "crypto_perps_section": "무기한 선물", "predictions": "예측", "no_results": "검색 결과가 없습니다", + "popular": "인기", "sites": "사이트", "popular_sites": "인기 사이트", "search_sites": "사이트 검색", @@ -8698,6 +8847,14 @@ "empty_search_result_state": { "title": "토큰을 찾을 수 없습니다", "description": "해당 토큰을 찾을 수 없습니다" + }, + "tabs": { + "now": "지금", + "macro": "거시 경제", + "rwas": "RWAs", + "crypto": "암호화폐", + "sports": "스포츠", + "dapps": "사이트" } }, "ota_update_modal": { @@ -8715,7 +8872,9 @@ }, "common": { "cancel": "취소", - "continue": "계속" + "continue": "계속", + "learn_more": "더 보기", + "try_again": "다시 시도" }, "connecting": { "title": "{{device}} 연결 중...", @@ -8761,6 +8920,7 @@ "bluetooth_off": "장치에 연결하려면 블루투스를 켜세요", "bluetooth_scan_failed": "장치를 스캔하지 못했습니다. 다시 시도하세요", "bluetooth_connection_failed": "기기 연결에 실패했습니다. 다시 시도하세요", + "camera_permission_denied": "Camera permission is required to scan QR codes. Please enable it in your device settings", "not_supported": "지원되지 않는 작업입니다", "unknown_error": "이 계정에 대한 비밀복구구문 또는 패스프레이즈로 {{device}}이(가) 설정되어 있는지 확인하세요" }, @@ -8784,8 +8944,35 @@ "location_permission_denied": "위치 권한 필요", "nearby_devices_permission_denied": "근처 장치 권한 필요", "scan_failed": "스캔 실패", + "camera_permission_denied": "Camera Permission Required", "something_went_wrong": "문제가 발생했습니다" }, + "qr_scan_errors": { + "non_ur_qr_scanned": { + "pair": { + "title": "Scan wallet sync QR code", + "body": "Go back to your hardware wallet and find the option to share or export your accounts, choose MetaMask, then scan the QR code." + }, + "sign": { + "title": "Scan signature QR code", + "body": "Go back to your hardware wallet and approve the transaction, then scan the QR code it shows." + } + }, + "wrong_ur_type": { + "pair": { + "title": "Wrong QR code", + "body": "Go back to your hardware wallet, choose MetaMask as the wallet type, then generate and scan a new QR code." + }, + "sign": { + "title": "Wrong QR code", + "body": "Go back to your hardware wallet and approve the transaction, then scan the QR code it shows." + } + }, + "ur_decode_error": { + "title": "Couldn't read QR code", + "body": "Make sure the QR code is fully visible and well-lit, and try again. If the issue continues, you may need to update your hardware wallet's firmware." + } + }, "device_selection": { "title": "{{device}} 선택", "scanning": "장치 스캔 중...", @@ -8803,6 +8990,14 @@ "cash": "머니", "cash_empty_description": "아직 mUSD가 없습니다. 홈 화면의 Money 섹션에서 스테이블코인을 mUSD로 전환하세요.", "cash_empty_description_network_filter": "이 네트워크에는 mUSD가 없습니다. mUSD를 확인하려면 네트워크를 전환하세요.", + "cash_empty_state": { + "get_started": "지금 시작하세요", + "earn_apy": "Earn {{percentage}}% APY" + }, + "cash_filled_state": { + "add": "추가", + "apy": "{{percentage}}% APY" + }, "tokens": "토큰", "perpetuals": "영구계약", "predictions": "예측", @@ -8818,7 +9013,7 @@ }, "defi": "디파이", "nfts": "NFT", - "trending_tokens": "트렌드", + "trending_tokens": "인기", "trending_perpetuals": "인기 무기한 선물", "trending_predictions": "인기 예측", "import_nfts": "NFT 가져오기", @@ -8832,5 +9027,8 @@ "unable_to_load": "{{section}} 불러올 수 없음", "retry": "다시 시도" } + }, + "sites": { + "popular": "인기" } } diff --git a/locales/languages/pt.json b/locales/languages/pt.json index ced9d6a3505..bc8a465f6fa 100644 --- a/locales/languages/pt.json +++ b/locales/languages/pt.json @@ -634,7 +634,8 @@ "trade": "Negociar", "settings": "Configurações", "rewards": "Recompensas", - "trending": "Explorar" + "trending": "Explorar", + "money": "Money" }, "drawer": { "send_button": "Enviar", @@ -2446,6 +2447,12 @@ } } }, + "payment": { + "change_payment_method": "Change Payment Method", + "add_funds": "Adicionar fundos", + "add": "Adicionar", + "predict_balance": "Predict balance" + }, "order": { "available": "Disponível", "cashed_out": "Sacado", @@ -3314,6 +3321,8 @@ "invalid_number_range": "Número inválido. Insira um número entre 1 e %{maxSafeChainId}", "hide_zero_balance_tokens_title": "Ocultar tokens sem saldo", "hide_zero_balance_tokens_desc": "Evita que tokens sem saldo sejam exibidos na sua listagem de tokens.", + "haptic_feedback_title": "Haptic feedback", + "haptic_feedback_desc": "Feel subtle vibrations for confirmations, errors, and interactions. Disable to reduce haptic stimuli.", "token_detection_title": "Detectar tokens automaticamente", "token_detection_description": "Usamos APIs de terceiros para detectar e exibir novos tokens enviados à sua carteira. Desative essa opção caso não queira que o app extraia dados desses serviços.", "theme_button_text": "Mudar tema", @@ -3457,7 +3466,7 @@ "title": "Headless buy", "loading": "Preparing the buy flow…", "no_session": "Headless session is no longer active.", - "cancel": "Cancel" + "cancel": "Cancelar" } }, "request_feature": "Solicitar um recurso", @@ -3572,6 +3581,39 @@ "title": "Cartão", "reset_onboarding_description": "Redefina o estado de integração do Cartão para iniciar o fluxo de integração desde o início.", "reset_onboarding_button": "Redefinir estado de integração" + }, + "haptics": { + "title": "Haptics", + "description": "Preview haptic feedback on a physical device. This bypasses in-app reduced-haptics and the remote kill switch so you can evaluate each pattern. Simulators often do not play haptics.", + "catalog_impacts_heading": "Catalog impact moments", + "notifications_heading": "Notification patterns", + "selection_heading": "Selection", + "notification_success_button": "Notification — Success", + "notification_error_button": "Notification — Error", + "notification_warning_button": "Notification — Warning", + "selection_button": "Selection", + "raw_impacts_heading": "Raw Expo impact styles", + "raw_impacts_desc": "Direct expo-haptics impact styles for comparison with the mapped catalog moments above.", + "impacts": { + "quick_amount_selection": "Impact — Quick amount selection", + "slider_tick": "Impact — Slider tick", + "edge_gesture_engage": "Impact — Edge gesture engage", + "page_navigation": "Impact — Page navigation", + "slider_grip": "Impact — Slider grip", + "tab_change": "Impact — Tab change", + "primary_cta": "Impact — Primary CTA", + "pull_to_refresh_engage": "Impact — Pull to refresh (engage)", + "pull_to_refresh": "Impact — Pull to refresh (release)", + "chart_crosshair": "Impact — Chart crosshair", + "follow_toggle": "Impact — Follow toggle" + }, + "raw_styles": { + "light": "Raw impact — Light", + "medium": "Raw impact — Medium", + "heavy": "Raw impact — Heavy", + "rigid": "Raw impact — Rigid", + "soft": "Raw impact — Soft" + } } }, "feature_flag_override": { @@ -6482,7 +6524,7 @@ "onboarding": { "step_progress": "Etapa {{current}} de {{total}}", "title": "Adicionar dinheiro", - "description": "Compre mUSD e comece a ganhar APY hoje mesmo.", + "description": "Fund your account and start earning APY.", "add": "Adicionar", "step2_title": "Peça o seu Cartão MetaMask", "step2_description": "Use o saldo do seu cartão Money enquanto ele rende, em qualquer lugar que aceite o Mastercard.", @@ -6499,7 +6541,8 @@ "earnings": { "title": "Ganhos", "lifetime": "Ganhos vitalícios", - "projected": "Ganhos projetados" + "projected": "Ganhos projetados", + "info_label": "Earnings info" }, "how_it_works": { "title": "Como funciona", @@ -6553,6 +6596,35 @@ "receive_external": "Receive from external wallet", "coming_soon": "Em breve" }, + "more_sheet": { + "title": "Mais", + "how_it_works": "Como funciona", + "what_you_get": "O que você recebe", + "contact_support": "Falar com o suporte" + }, + "transfer_sheet": { + "title": "Transfer money", + "between_accounts": "Between accounts", + "perps_account": "Perps account", + "predictions_account": "Predictions account", + "send_external": "Send to external address", + "withdraw_to_bank": "Withdraw to bank" + }, + "apy_tooltip": { + "title": "Annual Percentage Yield (APY)", + "paragraph_1": "Your Money account earns up to {{percentage}}% automatically.", + "paragraph_2": "The rate shown is an estimated Annual Percentage Yield (APY) based on current market conditions. APY is variable and may change due to various factors. The rate is generated by third-party DeFi platforms that deploy funds in blockchain protocols.", + "paragraph_3": "When you link your MetaMask Card, purchases will be deducted from this balance.", + "learn_more": "Saiba mais" + }, + "earnings_tooltip": { + "title": "Ganhos", + "lifetime_heading": "Ganhos vitalícios", + "lifetime_body": "The total yield you've earned since opening your Money account.", + "projected_heading": "Ganhos projetados", + "projected_body": "Uma projeção do que você ganharia em um ano com base no seu saldo e taxa atuais.", + "disclaimer": "Projection assumes {{percentage}}% Annual Percentage Yield (APY) remains unchanged for 1 year. APY is variable and may change due to various factors. No guarantee of return." + }, "activity": { "title": "Atividade", "view_all": "Exibir tudo", @@ -6917,7 +6989,9 @@ "perps_deposit": "Adicionar fundos", "perps_withdraw": "Sacar", "predict_deposit": "Adicionar fundos de previsão", - "predict_withdraw": "Sacar" + "predict_withdraw": "Sacar", + "money_account_add_money": "Adicionar dinheiro", + "money_account_transfer_money": "Transfer money" }, "sub_title": { "permit": "Este site quer permissão para gastar seus tokens.", @@ -6937,7 +7011,7 @@ "transaction_fee": "Trocaremos seus tokens por USDC na HyperCore, a rede usada pelos perps. Os provedores de troca podem cobrar uma taxa, mas a MetaMask não cobra." }, "predict_deposit": { - "transaction_fee": "Trocaremos seus tokens por USDC.e na Polygon, a rede usada pela Predictions. Provedores de swaps talvez cobrem taxas, mas a MetaMask não cobra." + "transaction_fee": "We'll swap your tokens for pUSD on Polygon, the network used by Predictions. Swap providers may charge a fee, but MetaMask won't." }, "perps_withdraw": { "transaction_fee": "A MetaMask fará a conversão para o token desejado para você. A MetaMask não aplica taxas quando você converte para mUSD." @@ -7033,7 +7107,8 @@ "custom_amount": { "buy_button": "Comprar criptomoedas", "buy_predict": "Adicione fundos à sua carteira para usar as Previsões.", - "buy_perps": "Adicione fundos à sua carteira para usar perps." + "buy_perps": "Adicione fundos à sua carteira para usar perps.", + "projected_five_year_balance": "Projected 5-year balance:" }, "unlimited": "Ilimitado", "all": "Tudo", @@ -7113,6 +7188,7 @@ "receive_at": "Receber em", "recipient": "Destinatário", "select_recipient": "Selecionar destinatário", + "select_account": "Selecionar conta", "external_account": "Conta externa", "error_banner_description": "Esta rota comercial não está disponível no momento. Experimente mudar o valor, a rede ou o token e encontraremos a melhor opção.", "stock_token_error_banner_description": "Esta rota de negociação não está disponível no momento. Tente alterar o valor, a rede ou o token e nós encontraremos a melhor opção.\n\nObserve que, se você estiver tentando negociar ações tokenizadas da Ondo (Ondo Tokenised Stocks), pode haver restrições geográficas, por exemplo, nos EUA, UE, Reino Unido e Brasil.", @@ -7791,6 +7867,7 @@ "enable_card_button_label": "Habilitar cartão", "enable_assets_button_label": "Habilitar ativos", "spending_limit_warning": "Você está perto de atingir seu limite de gastos. Atualize sua conta para evitar recusas.", + "spending_limit_available": "disponível", "logout": "Sair", "contact_support": "Falar com o suporte", "logout_confirmation_title": "Sair do cartão?", @@ -7925,7 +8002,12 @@ "withdrawal_success": "Saque concluído com sucesso", "withdrawal_failed": "Saque falhou. Tente novamente.", "no_cashback": "Sem cashback disponível", - "loading_error": "Falha ao carregar cashback. Tente novamente." + "loading_error": "Falha ao carregar cashback. Tente novamente.", + "funding_required": { + "title": "Set up Linea funding", + "description": "You need at least one approved funding source on Linea before redeeming cashback.", + "confirm_button_label": "Set up funding" + } }, "change_asset": { "title": "Alterar token e rede", @@ -8030,7 +8112,8 @@ "title": "Selecione o método de pagamento", "title_receive": "Selecionar token de recebimento", "no_gas": "Sem saldo nativo para gas", - "not_supported": "Sem suporte" + "not_supported": "Sem suporte", + "crypto": "Cripto" }, "connection_removed_modal": { "title": "Conexões removidas", @@ -8365,6 +8448,8 @@ "ends_date": "Termina em {{date}}", "ended_date": "Encerrado em {{date}}", "pill_up_next": "Em breve", + "up_next": "Em seguida", + "notify_me": "Avisar-me", "pill_active": "Em tempo real", "pill_complete": "Concluído", "enter": "Entrar", @@ -8378,7 +8463,9 @@ "geo_locked_toast_title": "Não disponível em sua região", "geo_locked_toast_description": "Esta campanha não está disponível em sua região. Volte mais tarde para conferir novas campanhas.", "geo_locked_cta": "Verificar elegibilidade", - "geo_loading": "Verificando região..." + "geo_loading": "Verificando região...", + "remind_me_success_toast": "We'll let you know when this campaign starts.", + "remind_me_save_error": "Could not save your reminder. Please try again." }, "campaign_mechanics": { "title": "Mecânica" @@ -8507,6 +8594,46 @@ "cancel": "Cancelar", "confirm": "Eu compreendo" }, + "perps_trading_campaign": { + "title": "Perps Trading Competition", + "stats_title": "Stats", + "performance_title": "Performance", + "label_rank": "Rank", + "label_your_rank": "Your rank", + "label_pnl": "PnL", + "label_volume": "Volume", + "label_notional_volume": "Notional volume", + "label_margin": "Margin", + "label_margin_deployed": "Margin deployed", + "pending": "Pending", + "qualified": "Qualified", + "open_position_cta": "Open Position", + "leaderboard_title": "Leaderboard", + "leaderboard_total_participants": "{{count}} participants", + "last_updated": "Last updated: {{time}}", + "leaderboard_error_loading": "Failed to load leaderboard", + "leaderboard_error_loading_description": "Something went wrong while loading the leaderboard. Please try again.", + "leaderboard_not_yet_computed": "Leaderboard hasn't been computed yet. Check back soon.", + "leaderboard_powered_by_prefix": "Powered by ", + "leaderboard_hypertracker_brand": "HyperTracker", + "prize_pool_title": "Prize pool", + "prize_pool_current_label": "Current", + "prize_pool_next_label": "Next", + "prize_pool_volume_subtext": "{{current}} of {{target}} volume", + "prize_pool_max_tier_subtext": "{{maxThreshold}}+ volume — all milestones reached", + "prize_pool_max_badge": "Max", + "prize_pool_error_title": "Failed to load prize pool", + "prize_pool_error_description": "There was an error loading the prize pool. Please try again.", + "prize_pool_retry_button": "Retry", + "stats_notional_volume_threshold": "${{amount}} required", + "stats_qualified_title": "You're qualified", + "stats_qualified_description": "Keep trading to improve your PnL and climb the leaderboard before the competition ends.", + "stats_qualify_for_rank_title": "Qualify for this rank", + "stats_qualify_for_rank_description": "Trade {{notionalRemaining}} more in notional volume to become eligible.", + "stats_error_title": "Unable to load stats", + "stats_error_description": "We had a problem loading your stats. Please try again.", + "stats_retry": "Retry" + }, "campaigns_preview": { "title": "Campanhas", "coming_soon": "Em breve", @@ -8661,7 +8788,9 @@ }, "trending": { "title": "Explorar", - "trending_tokens": "Tendências", + "crypto_movers": "Crypto movers", + "perps_movers": "Perps movers", + "trending_tokens": "Tendência", "stocks": "Ações", "price_change": "Variação de preço", "all_networks": "Todas as redes", @@ -8672,6 +8801,17 @@ "1_hour": "1 hora", "5_minutes": "5 minutos", "networks": "Redes", + "ecosystems": "Ecosystems", + "ecosystems_subtitle": "Explore dApps, tokens, and NFTs across chains on MetaMask Portfolio", + "all_sports": "Popular sports", + "basketball": "Basketball", + "football": "Football", + "soccer": "Soccer", + "tennis": "Tennis", + "f1": "F1", + "golf": "Golf", + "all_sports_no_markets": "No open markets in this category yet. Check back soon or pick another sport above.", + "load_more": "Load more", "sort_by": "Classificar por", "volume": "Volume", "market_cap": "Capitalização de mercado", @@ -8681,8 +8821,17 @@ "search_placeholder": "Pesquisar tokens, sites, URLs", "cancel": "Cancelar", "perps": "Perps", + "rwa_perps_section": "Mercados", + "macro_stocks_commodity_perps": "Stocks & commodities", + "macro_pill_stocks": "Ações", + "macro_pill_commodities": "Commodities", + "rwa_pill_commodities": "Commodities", + "rwa_pill_stocks": "Ações", + "rwa_pill_forex": "Forex", + "crypto_perps_section": "Perps", "predictions": "Previsões", "no_results": "Nenhum resultado encontrado", + "popular": "Populares", "sites": "Sites", "popular_sites": "Websites populares", "search_sites": "Pesquisar websites", @@ -8698,6 +8847,14 @@ "empty_search_result_state": { "title": "Nenhum token encontrado", "description": "Não foi possível encontrar este token" + }, + "tabs": { + "now": "Agora", + "macro": "Macro", + "rwas": "RWAs", + "crypto": "Cripto", + "sports": "Esportes", + "dapps": "Sites" } }, "ota_update_modal": { @@ -8715,7 +8872,9 @@ }, "common": { "cancel": "Cancelar", - "continue": "Continuar" + "continue": "Continuar", + "learn_more": "Saiba mais", + "try_again": "Tentar novamente" }, "connecting": { "title": "Conectando seu {{device}}...", @@ -8761,6 +8920,7 @@ "bluetooth_off": "Ative o Bluetooth para se conectar ao seu dispositivo", "bluetooth_scan_failed": "Falha ao procurar dispositivos. Tente novamente", "bluetooth_connection_failed": "A conexão com seu dispositivo falhou. Tente novamente", + "camera_permission_denied": "Camera permission is required to scan QR codes. Please enable it in your device settings", "not_supported": "Esta operação não é suportada", "unknown_error": "Certifique-se de que seu {{device}} esteja configurado com a Frase de Recuperação Secreta ou com a senha para esta conta" }, @@ -8784,8 +8944,35 @@ "location_permission_denied": "Necessário permissão de localização", "nearby_devices_permission_denied": "Necessário permissão para dispositivos próximos", "scan_failed": "Verificação falhou", + "camera_permission_denied": "Camera Permission Required", "something_went_wrong": "Ocorreu algum erro" }, + "qr_scan_errors": { + "non_ur_qr_scanned": { + "pair": { + "title": "Scan wallet sync QR code", + "body": "Go back to your hardware wallet and find the option to share or export your accounts, choose MetaMask, then scan the QR code." + }, + "sign": { + "title": "Scan signature QR code", + "body": "Go back to your hardware wallet and approve the transaction, then scan the QR code it shows." + } + }, + "wrong_ur_type": { + "pair": { + "title": "Wrong QR code", + "body": "Go back to your hardware wallet, choose MetaMask as the wallet type, then generate and scan a new QR code." + }, + "sign": { + "title": "Wrong QR code", + "body": "Go back to your hardware wallet and approve the transaction, then scan the QR code it shows." + } + }, + "ur_decode_error": { + "title": "Couldn't read QR code", + "body": "Make sure the QR code is fully visible and well-lit, and try again. If the issue continues, you may need to update your hardware wallet's firmware." + } + }, "device_selection": { "title": "Selecione {{device}}", "scanning": "Procurando dispositivos...", @@ -8803,6 +8990,14 @@ "cash": "Money", "cash_empty_description": "Você ainda não tem mUSD. Converta stablecoins para mUSD na seção \"Dinheiro\" da página inicial.", "cash_empty_description_network_filter": "Não há mUSD nesta rede. Mude de rede para ver seus mUSD.", + "cash_empty_state": { + "get_started": "Comece já", + "earn_apy": "Earn {{percentage}}% APY" + }, + "cash_filled_state": { + "add": "Adicionar", + "apy": "{{percentage}}% APY" + }, "tokens": "Tokens", "perpetuals": "Perpétuos", "predictions": "Previsões", @@ -8818,7 +9013,7 @@ }, "defi": "DeFi", "nfts": "NFTs", - "trending_tokens": "Tendências", + "trending_tokens": "Tendência", "trending_perpetuals": "Perpétuos em alta", "trending_predictions": "Previsões em alta", "import_nfts": "Importar NFTs", @@ -8832,5 +9027,8 @@ "unable_to_load": "Não foi possível carregar {{section}}", "retry": "Tentar novamente" } + }, + "sites": { + "popular": "Populares" } } diff --git a/locales/languages/ru.json b/locales/languages/ru.json index cbf3866685d..ef6b6f31ae2 100644 --- a/locales/languages/ru.json +++ b/locales/languages/ru.json @@ -634,7 +634,8 @@ "trade": "Торговать", "settings": "Настройки", "rewards": "Награды", - "trending": "Обзор" + "trending": "Обзор", + "money": "Финансы" }, "drawer": { "send_button": "Отправить", @@ -2446,6 +2447,12 @@ } } }, + "payment": { + "change_payment_method": "Change Payment Method", + "add_funds": "Внести средства", + "add": "Добавить", + "predict_balance": "Predict balance" + }, "order": { "available": "Доступно", "cashed_out": "Деньги выведены", @@ -3314,6 +3321,8 @@ "invalid_number_range": "Неверное число. Введите число между 1 и %{maxSafeChainId}", "hide_zero_balance_tokens_title": "Скрыть токены без баланса", "hide_zero_balance_tokens_desc": "Предотвращает отображение токенов без баланса в вашем списке токенов.", + "haptic_feedback_title": "Haptic feedback", + "haptic_feedback_desc": "Feel subtle vibrations for confirmations, errors, and interactions. Disable to reduce haptic stimuli.", "token_detection_title": "Автоопределение токенов", "token_detection_description": "Мы используем сторонние API для обнаружения и отображения новых токенов, отправленных в ваш кошелек. Отключите, если не хотите, чтобы приложение извлекало данные из этих служб.", "theme_button_text": "Изменить тему", @@ -3457,7 +3466,7 @@ "title": "Headless buy", "loading": "Preparing the buy flow…", "no_session": "Headless session is no longer active.", - "cancel": "Cancel" + "cancel": "Отмена" } }, "request_feature": "Запросить функцию", @@ -3572,6 +3581,39 @@ "title": "Карта", "reset_onboarding_description": "Сбросьте состояние начальной регистрации карты, чтобы начать процесс регистрации с самого начала.", "reset_onboarding_button": "Сбросить состояние начальной регистрации" + }, + "haptics": { + "title": "Haptics", + "description": "Preview haptic feedback on a physical device. This bypasses in-app reduced-haptics and the remote kill switch so you can evaluate each pattern. Simulators often do not play haptics.", + "catalog_impacts_heading": "Catalog impact moments", + "notifications_heading": "Notification patterns", + "selection_heading": "Selection", + "notification_success_button": "Notification — Success", + "notification_error_button": "Notification — Error", + "notification_warning_button": "Notification — Warning", + "selection_button": "Selection", + "raw_impacts_heading": "Raw Expo impact styles", + "raw_impacts_desc": "Direct expo-haptics impact styles for comparison with the mapped catalog moments above.", + "impacts": { + "quick_amount_selection": "Impact — Quick amount selection", + "slider_tick": "Impact — Slider tick", + "edge_gesture_engage": "Impact — Edge gesture engage", + "page_navigation": "Impact — Page navigation", + "slider_grip": "Impact — Slider grip", + "tab_change": "Impact — Tab change", + "primary_cta": "Impact — Primary CTA", + "pull_to_refresh_engage": "Impact — Pull to refresh (engage)", + "pull_to_refresh": "Impact — Pull to refresh (release)", + "chart_crosshair": "Impact — Chart crosshair", + "follow_toggle": "Impact — Follow toggle" + }, + "raw_styles": { + "light": "Raw impact — Light", + "medium": "Raw impact — Medium", + "heavy": "Raw impact — Heavy", + "rigid": "Raw impact — Rigid", + "soft": "Raw impact — Soft" + } } }, "feature_flag_override": { @@ -6482,7 +6524,7 @@ "onboarding": { "step_progress": "Шаг {{current}} из {{total}}", "title": "Пополнить", - "description": "Купите mUSD и начните зарабатывать годовой процентный доход (APY) уже сегодня.", + "description": "Fund your account and start earning APY.", "add": "Добавить", "step2_title": "Получите карту MetaMask Card", "step2_description": "Тратьте свой баланс Money, пока он приносит доход, везде, где принимают Mastercard.", @@ -6499,7 +6541,8 @@ "earnings": { "title": "Заработок", "lifetime": "Заработок за все время", - "projected": "Прогнозируемый заработок" + "projected": "Прогнозируемый заработок", + "info_label": "Earnings info" }, "how_it_works": { "title": "Как это работает", @@ -6553,6 +6596,35 @@ "receive_external": "Receive from external wallet", "coming_soon": "Скоро появятся" }, + "more_sheet": { + "title": "Еще", + "how_it_works": "Как это работает", + "what_you_get": "Что вы получаете", + "contact_support": "Связаться с поддержкой" + }, + "transfer_sheet": { + "title": "Transfer money", + "between_accounts": "Between accounts", + "perps_account": "Perps account", + "predictions_account": "Predictions account", + "send_external": "Send to external address", + "withdraw_to_bank": "Withdraw to bank" + }, + "apy_tooltip": { + "title": "Annual Percentage Yield (APY)", + "paragraph_1": "Your Money account earns up to {{percentage}}% automatically.", + "paragraph_2": "The rate shown is an estimated Annual Percentage Yield (APY) based on current market conditions. APY is variable and may change due to various factors. The rate is generated by third-party DeFi platforms that deploy funds in blockchain protocols.", + "paragraph_3": "When you link your MetaMask Card, purchases will be deducted from this balance.", + "learn_more": "Подробнее" + }, + "earnings_tooltip": { + "title": "Заработок", + "lifetime_heading": "Заработок за все время", + "lifetime_body": "The total yield you've earned since opening your Money account.", + "projected_heading": "Прогнозируемый заработок", + "projected_body": "Прогноз того, сколько вы заработаете за год, исходя из вашего текущего баланса и ставки.", + "disclaimer": "Projection assumes {{percentage}}% Annual Percentage Yield (APY) remains unchanged for 1 year. APY is variable and may change due to various factors. No guarantee of return." + }, "activity": { "title": "Деятельность", "view_all": "Смотреть все", @@ -6917,7 +6989,9 @@ "perps_deposit": "Внести средства", "perps_withdraw": "Вывести средства", "predict_deposit": "Внести средства для прогнозирования", - "predict_withdraw": "Вывести средства" + "predict_withdraw": "Вывести средства", + "money_account_add_money": "Пополнить", + "money_account_transfer_money": "Transfer money" }, "sub_title": { "permit": "Этот сайт запрашивает разрешение на трату ваших токенов.", @@ -6937,7 +7011,7 @@ "transaction_fee": "Мы обменяем ваши токены на USDC в HyperCore, сети, используемой перпами. Поставщики услуг свопов могут взимать комиссию, но MetaMask не взимает ее." }, "predict_deposit": { - "transaction_fee": "Мы обменяем ваши токены на USDC.e в Polygon, сети, используемой функцией «Прогнозы». Поставщики услуг свопов могут взимать комиссию, но MetaMask не взимает ее." + "transaction_fee": "We'll swap your tokens for pUSD on Polygon, the network used by Predictions. Swap providers may charge a fee, but MetaMask won't." }, "perps_withdraw": { "transaction_fee": "MetaMask автоматически обменяет ваш токен на желаемый. При обмене на mUSD комиссия MetaMask не взимается." @@ -7033,7 +7107,8 @@ "custom_amount": { "buy_button": "Купить криптовалюту", "buy_predict": "Добавьте средства в свой кошелек, чтобы воспользоваться функцией «Прогнозы».", - "buy_perps": "Добавьте средства в свой кошелек, чтобы использовать Перпы." + "buy_perps": "Добавьте средства в свой кошелек, чтобы использовать Перпы.", + "projected_five_year_balance": "Projected 5-year balance:" }, "unlimited": "Неограничено", "all": "Все", @@ -7113,6 +7188,7 @@ "receive_at": "Получить в", "recipient": "Получатель", "select_recipient": "Выберите получателя", + "select_account": "Выбрать счет", "external_account": "Внешний счет", "error_banner_description": "Этот торговый маршрут сейчас недоступен. Попробуйте изменить сумму, сеть или токен, и мы найдем лучший вариант.", "stock_token_error_banner_description": "Этот торговый маршрут в данный момент недоступен. Попробуйте изменить сумму, сеть или токен, и мы найдем наиболее подходящий вариант.\n\nОбратите внимание: при попытке торговли токенизированными акциями Ondo вы можете столкнуться с географическими ограничениями, например, с проблемами с доступом из США, ЕС, Великобритании и Бразилии.", @@ -7791,6 +7867,7 @@ "enable_card_button_label": "Включить карту", "enable_assets_button_label": "Активировать активы", "spending_limit_warning": "Вы почти достигли лимита расходов. Обновите, чтобы избежать отказов.", + "spending_limit_available": "доступные", "logout": "Выйти", "contact_support": "Связаться с поддержкой", "logout_confirmation_title": "Выйти из карты?", @@ -7925,7 +8002,12 @@ "withdrawal_success": "Вывод успешно завершен", "withdrawal_failed": "Ошибка вывода. Повторите попытку.", "no_cashback": "Нет доступного кешбэка", - "loading_error": "Не удалось загрузить кешбэк. Повторите попытку." + "loading_error": "Не удалось загрузить кешбэк. Повторите попытку.", + "funding_required": { + "title": "Set up Linea funding", + "description": "You need at least one approved funding source on Linea before redeeming cashback.", + "confirm_button_label": "Set up funding" + } }, "change_asset": { "title": "Измените токен и сеть", @@ -8030,7 +8112,8 @@ "title": "Выбрать способ оплаты", "title_receive": "Выберите токен для получения", "no_gas": "Нет нативного баланса для оплаты газа", - "not_supported": "Не поддерживается" + "not_supported": "Не поддерживается", + "crypto": "Крипто" }, "connection_removed_modal": { "title": "Соединения удалены", @@ -8365,6 +8448,8 @@ "ends_date": "Заканчивается {{date}}", "ended_date": "Завершено {{date}}", "pill_up_next": "Скоро появятся", + "up_next": "Далее", + "notify_me": "Уведомить меня", "pill_active": "Идет сейчас", "pill_complete": "Завершено", "enter": "Участвовать", @@ -8378,7 +8463,9 @@ "geo_locked_toast_title": "Недоступно в вашем регионе", "geo_locked_toast_description": "Данная кампания недоступна в вашем регионе. Загляните позже, чтобы узнать о новых кампаниях.", "geo_locked_cta": "Проверить доступность", - "geo_loading": "Проверка региона..." + "geo_loading": "Проверка региона...", + "remind_me_success_toast": "We'll let you know when this campaign starts.", + "remind_me_save_error": "Could not save your reminder. Please try again." }, "campaign_mechanics": { "title": "Механизм" @@ -8507,6 +8594,46 @@ "cancel": "Отмена", "confirm": "Я понимаю" }, + "perps_trading_campaign": { + "title": "Perps Trading Competition", + "stats_title": "Stats", + "performance_title": "Performance", + "label_rank": "Rank", + "label_your_rank": "Your rank", + "label_pnl": "PnL", + "label_volume": "Volume", + "label_notional_volume": "Notional volume", + "label_margin": "Margin", + "label_margin_deployed": "Margin deployed", + "pending": "Pending", + "qualified": "Qualified", + "open_position_cta": "Open Position", + "leaderboard_title": "Leaderboard", + "leaderboard_total_participants": "{{count}} participants", + "last_updated": "Last updated: {{time}}", + "leaderboard_error_loading": "Failed to load leaderboard", + "leaderboard_error_loading_description": "Something went wrong while loading the leaderboard. Please try again.", + "leaderboard_not_yet_computed": "Leaderboard hasn't been computed yet. Check back soon.", + "leaderboard_powered_by_prefix": "Powered by ", + "leaderboard_hypertracker_brand": "HyperTracker", + "prize_pool_title": "Prize pool", + "prize_pool_current_label": "Current", + "prize_pool_next_label": "Next", + "prize_pool_volume_subtext": "{{current}} of {{target}} volume", + "prize_pool_max_tier_subtext": "{{maxThreshold}}+ volume — all milestones reached", + "prize_pool_max_badge": "Max", + "prize_pool_error_title": "Failed to load prize pool", + "prize_pool_error_description": "There was an error loading the prize pool. Please try again.", + "prize_pool_retry_button": "Retry", + "stats_notional_volume_threshold": "${{amount}} required", + "stats_qualified_title": "You're qualified", + "stats_qualified_description": "Keep trading to improve your PnL and climb the leaderboard before the competition ends.", + "stats_qualify_for_rank_title": "Qualify for this rank", + "stats_qualify_for_rank_description": "Trade {{notionalRemaining}} more in notional volume to become eligible.", + "stats_error_title": "Unable to load stats", + "stats_error_description": "We had a problem loading your stats. Please try again.", + "stats_retry": "Retry" + }, "campaigns_preview": { "title": "Кампании", "coming_soon": "Скоро появятся", @@ -8661,7 +8788,9 @@ }, "trending": { "title": "Обзор", - "trending_tokens": "Тренды", + "crypto_movers": "Crypto movers", + "perps_movers": "Perps movers", + "trending_tokens": "Популярное", "stocks": "Акции", "price_change": "Изменение цены", "all_networks": "Все сети", @@ -8672,6 +8801,17 @@ "1_hour": "1 час", "5_minutes": "5 минут", "networks": "Сети", + "ecosystems": "Ecosystems", + "ecosystems_subtitle": "Explore dApps, tokens, and NFTs across chains on MetaMask Portfolio", + "all_sports": "Popular sports", + "basketball": "Basketball", + "football": "Football", + "soccer": "Soccer", + "tennis": "Tennis", + "f1": "F1", + "golf": "Golf", + "all_sports_no_markets": "No open markets in this category yet. Check back soon or pick another sport above.", + "load_more": "Load more", "sort_by": "Сортировать по", "volume": "Объем", "market_cap": "Рыночная капитализация", @@ -8681,8 +8821,17 @@ "search_placeholder": "Поиск токенов, сайтов и URL-адресов", "cancel": "Отмена", "perps": "Перпы", + "rwa_perps_section": "Рынки", + "macro_stocks_commodity_perps": "Stocks & commodities", + "macro_pill_stocks": "Акции", + "macro_pill_commodities": "Товары", + "rwa_pill_commodities": "Товары", + "rwa_pill_stocks": "Акции", + "rwa_pill_forex": "Валюты", + "crypto_perps_section": "Перпы", "predictions": "Прогнозы", "no_results": "Результаты не найдены", + "popular": "Популярные", "sites": "Сайты", "popular_sites": "Популярные сайты", "search_sites": "Поиск по сайтам", @@ -8698,6 +8847,14 @@ "empty_search_result_state": { "title": "Токены не найдены", "description": "Нам не удалось найти этот токен" + }, + "tabs": { + "now": "Сейчас", + "macro": "Макро", + "rwas": "RWAs", + "crypto": "Крипто", + "sports": "Спорт", + "dapps": "Сайты" } }, "ota_update_modal": { @@ -8715,7 +8872,9 @@ }, "common": { "cancel": "Отмена", - "continue": "Продолжить" + "continue": "Продолжить", + "learn_more": "Узнайте подробнее", + "try_again": "Повторить попытку" }, "connecting": { "title": "Подключение вашего {{device}}...", @@ -8761,6 +8920,7 @@ "bluetooth_off": "Включите Bluetooth для подключения к вашему устройству", "bluetooth_scan_failed": "Не удалось выполнить поиск устройств. Повторите попытку", "bluetooth_connection_failed": "Сбой подключения к вашему устройству. Повторите попытку", + "camera_permission_denied": "Camera permission is required to scan QR codes. Please enable it in your device settings", "not_supported": "Эта операция не поддерживается", "unknown_error": "Убедитесь, что ваш {{device}} настроен с помощью секретной фразой для восстановления или пароля для этого счета" }, @@ -8784,8 +8944,35 @@ "location_permission_denied": "Требуется разрешение на геолокацию", "nearby_devices_permission_denied": "Требуется разрешение на поиск устройств поблизости", "scan_failed": "Ошибка поиска", + "camera_permission_denied": "Camera Permission Required", "something_went_wrong": "Что-то пошло не так" }, + "qr_scan_errors": { + "non_ur_qr_scanned": { + "pair": { + "title": "Scan wallet sync QR code", + "body": "Go back to your hardware wallet and find the option to share or export your accounts, choose MetaMask, then scan the QR code." + }, + "sign": { + "title": "Scan signature QR code", + "body": "Go back to your hardware wallet and approve the transaction, then scan the QR code it shows." + } + }, + "wrong_ur_type": { + "pair": { + "title": "Wrong QR code", + "body": "Go back to your hardware wallet, choose MetaMask as the wallet type, then generate and scan a new QR code." + }, + "sign": { + "title": "Wrong QR code", + "body": "Go back to your hardware wallet and approve the transaction, then scan the QR code it shows." + } + }, + "ur_decode_error": { + "title": "Couldn't read QR code", + "body": "Make sure the QR code is fully visible and well-lit, and try again. If the issue continues, you may need to update your hardware wallet's firmware." + } + }, "device_selection": { "title": "Выберите {{device}}", "scanning": "Поиск устройств...", @@ -8803,6 +8990,14 @@ "cash": "Финансы", "cash_empty_description": "У вас пока нет mUSD. Конвертируйте стейблкоины в mUSD в разделе «Деньги» на главной странице.", "cash_empty_description_network_filter": "В этой сети нет mUSD. Переключитесь на другую сеть, чтобы увидеть свои mUSD.", + "cash_empty_state": { + "get_started": "С чего начать", + "earn_apy": "Earn {{percentage}}% APY" + }, + "cash_filled_state": { + "add": "Добавить", + "apy": "{{percentage}}% годовых" + }, "tokens": "Токены", "perpetuals": "Бессрочные контракты", "predictions": "Прогнозы", @@ -8818,7 +9013,7 @@ }, "defi": "DeFi", "nfts": "NFT", - "trending_tokens": "Тренды", + "trending_tokens": "Популярное", "trending_perpetuals": "Популярные перпы", "trending_predictions": "Популярные прогнозы", "import_nfts": "Импорт NFT", @@ -8832,5 +9027,8 @@ "unable_to_load": "Не удалось загрузить {{section}}", "retry": "Повтор" } + }, + "sites": { + "popular": "Популярные" } } diff --git a/locales/languages/tl.json b/locales/languages/tl.json index 9c1584e60d8..a1cc9eb084e 100644 --- a/locales/languages/tl.json +++ b/locales/languages/tl.json @@ -634,7 +634,8 @@ "trade": "Mag-trade", "settings": "Mga Setting", "rewards": "Mga Reward", - "trending": "Tuklasin" + "trending": "Tuklasin", + "money": "Pera" }, "drawer": { "send_button": "Ipadala", @@ -2446,6 +2447,12 @@ } } }, + "payment": { + "change_payment_method": "Change Payment Method", + "add_funds": "Magdagdag ng mga Pondo", + "add": "Idagdag", + "predict_balance": "Predict balance" + }, "order": { "available": "Available", "cashed_out": "Nag-cash out", @@ -3314,6 +3321,8 @@ "invalid_number_range": "Di-wastong numero. Maglagay ng numero sa pagitan ng 1 at %{maxSafeChainId}", "hide_zero_balance_tokens_title": "Itago ang mga token na walang balanse", "hide_zero_balance_tokens_desc": "Pigilan ang mga token na walang balanse na lumabas sa iyong listahan ng token.", + "haptic_feedback_title": "Haptic feedback", + "haptic_feedback_desc": "Feel subtle vibrations for confirmations, errors, and interactions. Disable to reduce haptic stimuli.", "token_detection_title": "I-autodetect ang mga token", "token_detection_description": "Gumagamit kami ng mga third-party na API para tumuklas at magpakita ng mga bagong token na ipinadala sa iyong wallet. I-off kung ayaw mong kunin ng app ang datos mula sa mga serbisyong iyon.", "theme_button_text": "Baguhin ang tema", @@ -3457,7 +3466,7 @@ "title": "Headless buy", "loading": "Preparing the buy flow…", "no_session": "Headless session is no longer active.", - "cancel": "Cancel" + "cancel": "Kanselahin" } }, "request_feature": "Humiling ng feature", @@ -3572,6 +3581,39 @@ "title": "Card", "reset_onboarding_description": "I-reset ang estado ng Card onboarding para simulan ang daloy ng onboarding mula sa simula.", "reset_onboarding_button": "I-reset ang Estado ng Onboarding" + }, + "haptics": { + "title": "Haptics", + "description": "Preview haptic feedback on a physical device. This bypasses in-app reduced-haptics and the remote kill switch so you can evaluate each pattern. Simulators often do not play haptics.", + "catalog_impacts_heading": "Catalog impact moments", + "notifications_heading": "Notification patterns", + "selection_heading": "Selection", + "notification_success_button": "Notification — Success", + "notification_error_button": "Notification — Error", + "notification_warning_button": "Notification — Warning", + "selection_button": "Selection", + "raw_impacts_heading": "Raw Expo impact styles", + "raw_impacts_desc": "Direct expo-haptics impact styles for comparison with the mapped catalog moments above.", + "impacts": { + "quick_amount_selection": "Impact — Quick amount selection", + "slider_tick": "Impact — Slider tick", + "edge_gesture_engage": "Impact — Edge gesture engage", + "page_navigation": "Impact — Page navigation", + "slider_grip": "Impact — Slider grip", + "tab_change": "Impact — Tab change", + "primary_cta": "Impact — Primary CTA", + "pull_to_refresh_engage": "Impact — Pull to refresh (engage)", + "pull_to_refresh": "Impact — Pull to refresh (release)", + "chart_crosshair": "Impact — Chart crosshair", + "follow_toggle": "Impact — Follow toggle" + }, + "raw_styles": { + "light": "Raw impact — Light", + "medium": "Raw impact — Medium", + "heavy": "Raw impact — Heavy", + "rigid": "Raw impact — Rigid", + "soft": "Raw impact — Soft" + } } }, "feature_flag_override": { @@ -6482,7 +6524,7 @@ "onboarding": { "step_progress": "Hakbang {{current}} ng {{total}}", "title": "Magdagdag ng pera", - "description": "Bumili ng mUSD at magsimulang kumita ng APY ngayong araw.", + "description": "Fund your account and start earning APY.", "add": "Idagdag", "step2_title": "Kunin ang MetaMask Card mo", "step2_description": "Gastusin ang balanse mo sa Money habang kumikita ito, saanman tinatanggap ang Mastercard.", @@ -6499,7 +6541,8 @@ "earnings": { "title": "Mga Kita", "lifetime": "Panghabambuhay na kita", - "projected": "Mga tinatayang kita" + "projected": "Mga tinatayang kita", + "info_label": "Earnings info" }, "how_it_works": { "title": "Paano ito gumagana", @@ -6553,6 +6596,35 @@ "receive_external": "Receive from external wallet", "coming_soon": "Paparating na" }, + "more_sheet": { + "title": "Higit pa", + "how_it_works": "Paano ito gumagana", + "what_you_get": "Ano ang makukuha mo", + "contact_support": "Kontakin ang suporta" + }, + "transfer_sheet": { + "title": "Transfer money", + "between_accounts": "Between accounts", + "perps_account": "Perps account", + "predictions_account": "Predictions account", + "send_external": "Send to external address", + "withdraw_to_bank": "Withdraw to bank" + }, + "apy_tooltip": { + "title": "Annual Percentage Yield (APY)", + "paragraph_1": "Your Money account earns up to {{percentage}}% automatically.", + "paragraph_2": "The rate shown is an estimated Annual Percentage Yield (APY) based on current market conditions. APY is variable and may change due to various factors. The rate is generated by third-party DeFi platforms that deploy funds in blockchain protocols.", + "paragraph_3": "When you link your MetaMask Card, purchases will be deducted from this balance.", + "learn_more": "Matuto pa" + }, + "earnings_tooltip": { + "title": "Mga Kita", + "lifetime_heading": "Panghabambuhay na kita", + "lifetime_body": "The total yield you've earned since opening your Money account.", + "projected_heading": "Mga tinatayang kita", + "projected_body": "Ang pagtataya sa kikitain mo sa isang taon batay sa kasalukuyan mong balanse at rate.", + "disclaimer": "Projection assumes {{percentage}}% Annual Percentage Yield (APY) remains unchanged for 1 year. APY is variable and may change due to various factors. No guarantee of return." + }, "activity": { "title": "Aktibidad", "view_all": "Tingnan lahat", @@ -6917,7 +6989,9 @@ "perps_deposit": "Magdagdag ng pondo", "perps_withdraw": "Mag-withdraw", "predict_deposit": "Magdagdag ng mga pondo para sa Prediksyon", - "predict_withdraw": "Mag-withdraw" + "predict_withdraw": "Mag-withdraw", + "money_account_add_money": "Magdagdag ng pera", + "money_account_transfer_money": "Transfer money" }, "sub_title": { "permit": "Kailangan ng site na ito ng pahintulot para gastusin ang mga token mo.", @@ -6937,7 +7011,7 @@ "transaction_fee": "Isa-swap namin ang iyong mga token para sa USDC sa HyperCore, ang network na ginagamit ng Perps. Maaaring maningil ng bayarin ang mga swap provider, ngunit hindi ang MetaMask." }, "predict_deposit": { - "transaction_fee": "Isu-swap namin ang mga token mo para sa USDE.e sa Polygon, ang network na ginamit ng mga Prediksyon. Maaaring maningil ang mga swap provider, ngunit hindi ang MetaMask." + "transaction_fee": "We'll swap your tokens for pUSD on Polygon, the network used by Predictions. Swap providers may charge a fee, but MetaMask won't." }, "perps_withdraw": { "transaction_fee": "Magsu-swap ang MetaMask ng token na gusto mo para sa iyo. Walang hihinging bayad sa MetaMask kapag nag-swap ka sa mUSD." @@ -7033,7 +7107,8 @@ "custom_amount": { "buy_button": "Bumili ng crypto", "buy_predict": "Magdagdag ng pondo sa wallet mo para magamit ang Mga Prediksyon.", - "buy_perps": "Magdagdag ng pondo sa wallet mo para magamit ang Perps." + "buy_perps": "Magdagdag ng pondo sa wallet mo para magamit ang Perps.", + "projected_five_year_balance": "Projected 5-year balance:" }, "unlimited": "Walang limitasyon", "all": "Lahat", @@ -7113,6 +7188,7 @@ "receive_at": "Natanggap sa", "recipient": "Tatanggap", "select_recipient": "Pumili ng tatanggap", + "select_account": "Pumili ng account", "external_account": "External account", "error_banner_description": "Hindi available ang ruta ng trade na ito sa ngayon. Subukang baguhin ang halaga, network, o token at hahanapin namin ang pinakamainam na solusyon.", "stock_token_error_banner_description": "Hindi available ang ruta ng trade na ito sa ngayon. Subukang baguhin ang halaga, network, o token at hahanapin namin ang pinakamainam na opsyon.\n\nPakitandaan na kung sinusubukan mong mag-trade ng Ondo Tokenised na mga Stock, maaari geo-restricted ka hal. sa pamamagitan ng US, EU, UK at BR.", @@ -7791,6 +7867,7 @@ "enable_card_button_label": "Paganahin ang card", "enable_assets_button_label": "I-enable ang mga asset", "spending_limit_warning": "Malapit mo nang maabot ang limit ng paggastos. Mag-update para maiwasan ang hindi pagtanggap.", + "spending_limit_available": "available", "logout": "Mag-log out", "contact_support": "Kontakin ang suporta", "logout_confirmation_title": "I-log out ang Card?", @@ -7925,7 +8002,12 @@ "withdrawal_success": "Matagumpay na nakumpleto ang pag-withdraw", "withdrawal_failed": "Pumalya ang pag-withdraw. Subukan ulit mamaya.", "no_cashback": "Walang cashback na available", - "loading_error": "Hindi nakapag-load ng cashback. Subukan ulit." + "loading_error": "Hindi nakapag-load ng cashback. Subukan ulit.", + "funding_required": { + "title": "Set up Linea funding", + "description": "You need at least one approved funding source on Linea before redeeming cashback.", + "confirm_button_label": "Set up funding" + } }, "change_asset": { "title": "Palitan ang token at network", @@ -8030,7 +8112,8 @@ "title": "Pumili ng paraan ng pagbabayad", "title_receive": "Piliin ang tatanggaping token", "no_gas": "Walang native na balanse para sa gas", - "not_supported": "Hindi suportado" + "not_supported": "Hindi suportado", + "crypto": "Crypto" }, "connection_removed_modal": { "title": "Naalis na ang mga koneksyon", @@ -8365,6 +8448,8 @@ "ends_date": "Matatapos sa {{date}}", "ended_date": "Natapos {{date}}", "pill_up_next": "Paparating na", + "up_next": "Susunod", + "notify_me": "Abisuhan ako", "pill_active": "Live", "pill_complete": "Kumpleto na", "enter": "Ilagay", @@ -8378,7 +8463,9 @@ "geo_locked_toast_title": "Hindi available sa rehiyon mo", "geo_locked_toast_description": "Hindi available ang campaign na ito sa lugar kung nasaan ka. Bumalik sa ibang pagkakataon para sa mga bagong campaign.", "geo_locked_cta": "Alamin kung kwalipikado", - "geo_loading": "Sinusuri ang rehiyon..." + "geo_loading": "Sinusuri ang rehiyon...", + "remind_me_success_toast": "We'll let you know when this campaign starts.", + "remind_me_save_error": "Could not save your reminder. Please try again." }, "campaign_mechanics": { "title": "Mechanics" @@ -8507,6 +8594,46 @@ "cancel": "Kanselahin", "confirm": "Nauunawaan ko" }, + "perps_trading_campaign": { + "title": "Perps Trading Competition", + "stats_title": "Stats", + "performance_title": "Performance", + "label_rank": "Rank", + "label_your_rank": "Your rank", + "label_pnl": "PnL", + "label_volume": "Volume", + "label_notional_volume": "Notional volume", + "label_margin": "Margin", + "label_margin_deployed": "Margin deployed", + "pending": "Pending", + "qualified": "Qualified", + "open_position_cta": "Open Position", + "leaderboard_title": "Leaderboard", + "leaderboard_total_participants": "{{count}} participants", + "last_updated": "Last updated: {{time}}", + "leaderboard_error_loading": "Failed to load leaderboard", + "leaderboard_error_loading_description": "Something went wrong while loading the leaderboard. Please try again.", + "leaderboard_not_yet_computed": "Leaderboard hasn't been computed yet. Check back soon.", + "leaderboard_powered_by_prefix": "Powered by ", + "leaderboard_hypertracker_brand": "HyperTracker", + "prize_pool_title": "Prize pool", + "prize_pool_current_label": "Current", + "prize_pool_next_label": "Next", + "prize_pool_volume_subtext": "{{current}} of {{target}} volume", + "prize_pool_max_tier_subtext": "{{maxThreshold}}+ volume — all milestones reached", + "prize_pool_max_badge": "Max", + "prize_pool_error_title": "Failed to load prize pool", + "prize_pool_error_description": "There was an error loading the prize pool. Please try again.", + "prize_pool_retry_button": "Retry", + "stats_notional_volume_threshold": "${{amount}} required", + "stats_qualified_title": "You're qualified", + "stats_qualified_description": "Keep trading to improve your PnL and climb the leaderboard before the competition ends.", + "stats_qualify_for_rank_title": "Qualify for this rank", + "stats_qualify_for_rank_description": "Trade {{notionalRemaining}} more in notional volume to become eligible.", + "stats_error_title": "Unable to load stats", + "stats_error_description": "We had a problem loading your stats. Please try again.", + "stats_retry": "Retry" + }, "campaigns_preview": { "title": "Mga campaign", "coming_soon": "Paparating na", @@ -8661,7 +8788,9 @@ }, "trending": { "title": "Tuklasin", - "trending_tokens": "Trending", + "crypto_movers": "Crypto movers", + "perps_movers": "Perps movers", + "trending_tokens": "Nagte-trend", "stocks": "Stocks", "price_change": "Pagbabago ng presyo", "all_networks": "Lahat ng network", @@ -8672,6 +8801,17 @@ "1_hour": "1 oras", "5_minutes": "5 minuto", "networks": "Mga Network", + "ecosystems": "Ecosystems", + "ecosystems_subtitle": "Explore dApps, tokens, and NFTs across chains on MetaMask Portfolio", + "all_sports": "Popular sports", + "basketball": "Basketball", + "football": "Football", + "soccer": "Soccer", + "tennis": "Tennis", + "f1": "F1", + "golf": "Golf", + "all_sports_no_markets": "No open markets in this category yet. Check back soon or pick another sport above.", + "load_more": "Load more", "sort_by": "I-sort ayon sa", "volume": "Volume", "market_cap": "Market cap", @@ -8681,8 +8821,17 @@ "search_placeholder": "Maghanap ng mga token, site, URL", "cancel": "Kanselahin", "perps": "Perps", + "rwa_perps_section": "Market", + "macro_stocks_commodity_perps": "Stocks & commodities", + "macro_pill_stocks": "Stocks", + "macro_pill_commodities": "Mga Commodity", + "rwa_pill_commodities": "Mga Commodity", + "rwa_pill_stocks": "Stocks", + "rwa_pill_forex": "Forex", + "crypto_perps_section": "Perps", "predictions": "Mga hula", "no_results": "Walang nahanap na mga resulta", + "popular": "Sikat", "sites": "Mga Site", "popular_sites": "Mga sikat na site", "search_sites": "Maghanap ng mga site", @@ -8698,6 +8847,14 @@ "empty_search_result_state": { "title": "Walang nakitang token", "description": "Hindi namin nahanap ang token na ito" + }, + "tabs": { + "now": "Ngayon", + "macro": "Makro", + "rwas": "RWAs", + "crypto": "Crypto", + "sports": "Palakasan", + "dapps": "Mga Site" } }, "ota_update_modal": { @@ -8715,7 +8872,9 @@ }, "common": { "cancel": "Kanselahin", - "continue": "Magpatuloy" + "continue": "Magpatuloy", + "learn_more": "Matuto pa", + "try_again": "Subukang muli" }, "connecting": { "title": "Ikinokonekta ang iyong {{device}}...", @@ -8761,6 +8920,7 @@ "bluetooth_off": "I-on ang Bluetooth para kumonekta sa device mo", "bluetooth_scan_failed": "Hindi nakapag-scan ng mga device. Subukan ulit", "bluetooth_connection_failed": "Pumalya ang koneksyon sa iyong device. Pakisubukan muli", + "camera_permission_denied": "Camera permission is required to scan QR codes. Please enable it in your device settings", "not_supported": "Hindi sinusuportahan ang operasyong ito", "unknown_error": "Siguraduhing may naka-set up na Lihim na Parirala sa Pagbawi o passphrase ang {{device}} mo para sa account na ito" }, @@ -8784,8 +8944,35 @@ "location_permission_denied": "Kinakailangan ang Pahintulot sa Lokasyon", "nearby_devices_permission_denied": "Kinakailangan ang Pahintulot sa Mga Device sa Paligid", "scan_failed": "Pumalya ang Pag-scan", + "camera_permission_denied": "Camera Permission Required", "something_went_wrong": "Mayroong nang mali" }, + "qr_scan_errors": { + "non_ur_qr_scanned": { + "pair": { + "title": "Scan wallet sync QR code", + "body": "Go back to your hardware wallet and find the option to share or export your accounts, choose MetaMask, then scan the QR code." + }, + "sign": { + "title": "Scan signature QR code", + "body": "Go back to your hardware wallet and approve the transaction, then scan the QR code it shows." + } + }, + "wrong_ur_type": { + "pair": { + "title": "Wrong QR code", + "body": "Go back to your hardware wallet, choose MetaMask as the wallet type, then generate and scan a new QR code." + }, + "sign": { + "title": "Wrong QR code", + "body": "Go back to your hardware wallet and approve the transaction, then scan the QR code it shows." + } + }, + "ur_decode_error": { + "title": "Couldn't read QR code", + "body": "Make sure the QR code is fully visible and well-lit, and try again. If the issue continues, you may need to update your hardware wallet's firmware." + } + }, "device_selection": { "title": "Piliin ang {{device}}", "scanning": "Nagsa-scan ng mga device...", @@ -8803,6 +8990,14 @@ "cash": "Pera", "cash_empty_description": "Wala ka pang kahit anong mUSD. I-convert ang mga stablecoin papuntang mUSD mula sa seksyon ng Pera sa homepage.", "cash_empty_description_network_filter": "Walang mUSD sa network na ito. Lumipat ng network para makita ang mUSD mo.", + "cash_empty_state": { + "get_started": "Magsimula", + "earn_apy": "Earn {{percentage}}% APY" + }, + "cash_filled_state": { + "add": "Idagdag", + "apy": "{{percentage}}% APY" + }, "tokens": "Mga Token", "perpetuals": "Perpetuals", "predictions": "Mga hula", @@ -8818,7 +9013,7 @@ }, "defi": "DeFi", "nfts": "Mga NFT", - "trending_tokens": "Trending", + "trending_tokens": "Nagte-trend", "trending_perpetuals": "Mga trending na perpetual", "trending_predictions": "Mga trending na prediksyon", "import_nfts": "Mag-import ng mga NFT", @@ -8832,5 +9027,8 @@ "unable_to_load": "Hindi nai-load ang {{section}}", "retry": "Subukang muli" } + }, + "sites": { + "popular": "Sikat" } } diff --git a/locales/languages/tr.json b/locales/languages/tr.json index 13665eb75ca..3fc8b5216ac 100644 --- a/locales/languages/tr.json +++ b/locales/languages/tr.json @@ -634,7 +634,8 @@ "trade": "İşlem Yap", "settings": "Ayarlar", "rewards": "Ödüller", - "trending": "Keşfet" + "trending": "Keşfet", + "money": "Para" }, "drawer": { "send_button": "Gönder", @@ -2446,6 +2447,12 @@ } } }, + "payment": { + "change_payment_method": "Change Payment Method", + "add_funds": "Fon Ekle", + "add": "Ekle", + "predict_balance": "Predict balance" + }, "order": { "available": "Kullanılabilir", "cashed_out": "Nakit çıkışı yapıldı", @@ -3314,6 +3321,8 @@ "invalid_number_range": "Geçersiz sayı. 1 ile %{maxSafeChainId} arasında bir sayı girin.", "hide_zero_balance_tokens_title": "Bakiyesi olmayan tokenları gizle", "hide_zero_balance_tokens_desc": "Bakiyesi olmayan tokenlerin token listenizde gösterilmesini önler.", + "haptic_feedback_title": "Haptic feedback", + "haptic_feedback_desc": "Feel subtle vibrations for confirmations, errors, and interactions. Disable to reduce haptic stimuli.", "token_detection_title": "Token'leri otomatik algıla", "token_detection_description": "Cüzdanınıza gönderilen yeni tokenleri algılamak ve göstermek için üçüncü taraf API'leri kullanırız. Bu hizmetlerden veri çekmek istemiyorsanız kapatın.", "theme_button_text": "Temayı değiştir", @@ -3457,7 +3466,7 @@ "title": "Headless buy", "loading": "Preparing the buy flow…", "no_session": "Headless session is no longer active.", - "cancel": "Cancel" + "cancel": "İptal et" } }, "request_feature": "Bir özellik talep et", @@ -3572,6 +3581,39 @@ "title": "Kart", "reset_onboarding_description": "Kayıt akışını baştan başlatmak için Kart kayıt durumunu sıfırlayın.", "reset_onboarding_button": "Kayıt Durumunu Sıfırla" + }, + "haptics": { + "title": "Haptics", + "description": "Preview haptic feedback on a physical device. This bypasses in-app reduced-haptics and the remote kill switch so you can evaluate each pattern. Simulators often do not play haptics.", + "catalog_impacts_heading": "Catalog impact moments", + "notifications_heading": "Notification patterns", + "selection_heading": "Selection", + "notification_success_button": "Notification — Success", + "notification_error_button": "Notification — Error", + "notification_warning_button": "Notification — Warning", + "selection_button": "Selection", + "raw_impacts_heading": "Raw Expo impact styles", + "raw_impacts_desc": "Direct expo-haptics impact styles for comparison with the mapped catalog moments above.", + "impacts": { + "quick_amount_selection": "Impact — Quick amount selection", + "slider_tick": "Impact — Slider tick", + "edge_gesture_engage": "Impact — Edge gesture engage", + "page_navigation": "Impact — Page navigation", + "slider_grip": "Impact — Slider grip", + "tab_change": "Impact — Tab change", + "primary_cta": "Impact — Primary CTA", + "pull_to_refresh_engage": "Impact — Pull to refresh (engage)", + "pull_to_refresh": "Impact — Pull to refresh (release)", + "chart_crosshair": "Impact — Chart crosshair", + "follow_toggle": "Impact — Follow toggle" + }, + "raw_styles": { + "light": "Raw impact — Light", + "medium": "Raw impact — Medium", + "heavy": "Raw impact — Heavy", + "rigid": "Raw impact — Rigid", + "soft": "Raw impact — Soft" + } } }, "feature_flag_override": { @@ -6482,7 +6524,7 @@ "onboarding": { "step_progress": "Adım {{current}}/{{total}}", "title": "Para ekle", - "description": "Bugün mUSD alın ve APY kazanmaya başlayın.", + "description": "Fund your account and start earning APY.", "add": "Ekle", "step2_title": "MetaMask Card'ınızı alın", "step2_description": "Para bakiyenizi Mastercard kabul edilen her yerde kazanırken harcamaya devam edin.", @@ -6499,7 +6541,8 @@ "earnings": { "title": "Kazançlar", "lifetime": "Toplam kazançlar", - "projected": "Öngörülen kazançlar" + "projected": "Öngörülen kazançlar", + "info_label": "Earnings info" }, "how_it_works": { "title": "Nasıl çalışır?", @@ -6553,6 +6596,35 @@ "receive_external": "Receive from external wallet", "coming_soon": "Çok yakında" }, + "more_sheet": { + "title": "Daha fazla", + "how_it_works": "Nasıl çalışır?", + "what_you_get": "Alacağınız tutar", + "contact_support": "Destek ile iletişime geç" + }, + "transfer_sheet": { + "title": "Transfer money", + "between_accounts": "Between accounts", + "perps_account": "Perps account", + "predictions_account": "Predictions account", + "send_external": "Send to external address", + "withdraw_to_bank": "Withdraw to bank" + }, + "apy_tooltip": { + "title": "Annual Percentage Yield (APY)", + "paragraph_1": "Your Money account earns up to {{percentage}}% automatically.", + "paragraph_2": "The rate shown is an estimated Annual Percentage Yield (APY) based on current market conditions. APY is variable and may change due to various factors. The rate is generated by third-party DeFi platforms that deploy funds in blockchain protocols.", + "paragraph_3": "When you link your MetaMask Card, purchases will be deducted from this balance.", + "learn_more": "Daha fazla bilgi edin" + }, + "earnings_tooltip": { + "title": "Kazançlar", + "lifetime_heading": "Toplam kazançlar", + "lifetime_body": "The total yield you've earned since opening your Money account.", + "projected_heading": "Öngörülen kazançlar", + "projected_body": "Mevcut bakiyenize ve oranınıza göre yıllık kazanç tahmininiz.", + "disclaimer": "Projection assumes {{percentage}}% Annual Percentage Yield (APY) remains unchanged for 1 year. APY is variable and may change due to various factors. No guarantee of return." + }, "activity": { "title": "Aktivite", "view_all": "Tümünü görüntüle", @@ -6917,7 +6989,9 @@ "perps_deposit": "Fon ekle", "perps_withdraw": "Çek", "predict_deposit": "Tahmin fonu ekle", - "predict_withdraw": "Çek" + "predict_withdraw": "Çek", + "money_account_add_money": "Para ekle", + "money_account_transfer_money": "Transfer money" }, "sub_title": { "permit": "Bu site token'larınızı harcamak için izin istiyor.", @@ -6937,7 +7011,7 @@ "transaction_fee": "Tokenlerinizi Sürekli Vadeli İşlem Sözleşmelerinin kullandığı HyperCore ağı üzerinden USDC’ye çevireceğiz. Takas sağlayıcıları ücret alabilir, ancak MetaMask ücret talep etmez." }, "predict_deposit": { - "transaction_fee": "Tokenlerinizi Tahminler tarafından kullanılan Polygon ağı üzerinden USDC ile takas edeceğiz. Takas sağlayıcıları ücret alabilir ancak MetaMask ücret talep etmez." + "transaction_fee": "We'll swap your tokens for pUSD on Polygon, the network used by Predictions. Swap providers may charge a fee, but MetaMask won't." }, "perps_withdraw": { "transaction_fee": "MetaMask, sizin için istediğiniz tokena takas gerçekleştirecek. mUSD'ye takas gerçekleştirirken MetaMask ücreti uygulanmaz." @@ -7033,7 +7107,8 @@ "custom_amount": { "buy_button": "Kripto satın alın", "buy_predict": "Tahminleri kullanmak için cüzdanınıza fon ekleyin.", - "buy_perps": "Sürekli Vadeli İşlem Sözleşmelerini kullanmak için cüzdanınıza fon ekleyin." + "buy_perps": "Sürekli Vadeli İşlem Sözleşmelerini kullanmak için cüzdanınıza fon ekleyin.", + "projected_five_year_balance": "Projected 5-year balance:" }, "unlimited": "Sınırsız", "all": "Tümü", @@ -7113,6 +7188,7 @@ "receive_at": "Şurada alınacak:", "recipient": "Alıcı", "select_recipient": "Alıcı seç", + "select_account": "Hesap seç", "external_account": "Harici hesap", "error_banner_description": "Bu işlem rotası şu anda kullanılamıyor. Tutarı, ağı veya token'ı değiştirmeyi deneyin ve sizin için en iyi seçeneği bulalım.", "stock_token_error_banner_description": "Bu işlem rotası şu anda kullanılamıyor. Tutarı, ağı veya tokenı değiştirmeyi deneyin ve en iyi seçeneği bulalım.\n\nOndo Tokenize Edilmiş Hisse Senetleri ile işlem yapmaya çalışıyorsanız ABD, AB, Birleşik Krallık ve Brezilya gibi bölgelerde coğrafi kısıtlamaya tabi olabilirsiniz.", @@ -7791,6 +7867,7 @@ "enable_card_button_label": "Kartı etkinleştir", "enable_assets_button_label": "Varlıkları etkinleştir", "spending_limit_warning": "Harcama limitinize yaklaştınız. Reddedilmeleri önlemek için güncelleyin.", + "spending_limit_available": "kullanılabilir", "logout": "Oturumu kapat", "contact_support": "Destek ile iletişime geç", "logout_confirmation_title": "Kart oturumu kapatılsın mı?", @@ -7925,7 +8002,12 @@ "withdrawal_success": "Para çekme başarıyla tamamlandı", "withdrawal_failed": "Para çekme başarısız oldu. Lütfen tekrar deneyin.", "no_cashback": "Para iadesi mevcut değil", - "loading_error": "Para iadesi yüklenemedi. Lütfen tekrar deneyin." + "loading_error": "Para iadesi yüklenemedi. Lütfen tekrar deneyin.", + "funding_required": { + "title": "Set up Linea funding", + "description": "You need at least one approved funding source on Linea before redeeming cashback.", + "confirm_button_label": "Set up funding" + } }, "change_asset": { "title": "Token'ı ve ağı değiştir", @@ -8030,7 +8112,8 @@ "title": "Ödeme yöntemi seç", "title_receive": "Token al seçeneğini seç", "no_gas": "Gaz için yerel bakiye yok", - "not_supported": "Desteklenmiyor" + "not_supported": "Desteklenmiyor", + "crypto": "Kripto" }, "connection_removed_modal": { "title": "Bağlantılar kaldırıldı", @@ -8365,6 +8448,8 @@ "ends_date": "Bitiş tarihi {{date}}", "ended_date": "Bitiş tarihi {{date}}", "pill_up_next": "Çok yakında", + "up_next": "Sırada", + "notify_me": "Bana bildir", "pill_active": "Canlı", "pill_complete": "Tamamlandı", "enter": "Giriş yap", @@ -8378,7 +8463,9 @@ "geo_locked_toast_title": "Bölgenizde kullanılamıyor", "geo_locked_toast_description": "Bu kampanya bulunduğunuz yerde kullanılamıyor. Yeni kampanyalar için daha sonra tekrar kontrol edin.", "geo_locked_cta": "Uygunluğu kontrol et", - "geo_loading": "Bölge kontrol ediliyor..." + "geo_loading": "Bölge kontrol ediliyor...", + "remind_me_success_toast": "We'll let you know when this campaign starts.", + "remind_me_save_error": "Could not save your reminder. Please try again." }, "campaign_mechanics": { "title": "İşleyiş" @@ -8507,6 +8594,46 @@ "cancel": "İptal et", "confirm": "Anlıyorum" }, + "perps_trading_campaign": { + "title": "Perps Trading Competition", + "stats_title": "Stats", + "performance_title": "Performance", + "label_rank": "Rank", + "label_your_rank": "Your rank", + "label_pnl": "PnL", + "label_volume": "Volume", + "label_notional_volume": "Notional volume", + "label_margin": "Margin", + "label_margin_deployed": "Margin deployed", + "pending": "Pending", + "qualified": "Qualified", + "open_position_cta": "Open Position", + "leaderboard_title": "Leaderboard", + "leaderboard_total_participants": "{{count}} participants", + "last_updated": "Last updated: {{time}}", + "leaderboard_error_loading": "Failed to load leaderboard", + "leaderboard_error_loading_description": "Something went wrong while loading the leaderboard. Please try again.", + "leaderboard_not_yet_computed": "Leaderboard hasn't been computed yet. Check back soon.", + "leaderboard_powered_by_prefix": "Powered by ", + "leaderboard_hypertracker_brand": "HyperTracker", + "prize_pool_title": "Prize pool", + "prize_pool_current_label": "Current", + "prize_pool_next_label": "Next", + "prize_pool_volume_subtext": "{{current}} of {{target}} volume", + "prize_pool_max_tier_subtext": "{{maxThreshold}}+ volume — all milestones reached", + "prize_pool_max_badge": "Max", + "prize_pool_error_title": "Failed to load prize pool", + "prize_pool_error_description": "There was an error loading the prize pool. Please try again.", + "prize_pool_retry_button": "Retry", + "stats_notional_volume_threshold": "${{amount}} required", + "stats_qualified_title": "You're qualified", + "stats_qualified_description": "Keep trading to improve your PnL and climb the leaderboard before the competition ends.", + "stats_qualify_for_rank_title": "Qualify for this rank", + "stats_qualify_for_rank_description": "Trade {{notionalRemaining}} more in notional volume to become eligible.", + "stats_error_title": "Unable to load stats", + "stats_error_description": "We had a problem loading your stats. Please try again.", + "stats_retry": "Retry" + }, "campaigns_preview": { "title": "Kampanyalar", "coming_soon": "Çok yakında", @@ -8661,6 +8788,8 @@ }, "trending": { "title": "Keşfet", + "crypto_movers": "Crypto movers", + "perps_movers": "Perps movers", "trending_tokens": "Trend", "stocks": "Hisse Senetleri", "price_change": "Fiyat değişikliği", @@ -8672,6 +8801,17 @@ "1_hour": "1 saat", "5_minutes": "5 dakika", "networks": "Ağlar", + "ecosystems": "Ecosystems", + "ecosystems_subtitle": "Explore dApps, tokens, and NFTs across chains on MetaMask Portfolio", + "all_sports": "Popular sports", + "basketball": "Basketball", + "football": "Football", + "soccer": "Soccer", + "tennis": "Tennis", + "f1": "F1", + "golf": "Golf", + "all_sports_no_markets": "No open markets in this category yet. Check back soon or pick another sport above.", + "load_more": "Load more", "sort_by": "Şuna göre sırala", "volume": "Hacim", "market_cap": "Piyasa değeri", @@ -8681,8 +8821,17 @@ "search_placeholder": "Token, site, URL ara", "cancel": "İptal", "perps": "Sürekli Vadeli", + "rwa_perps_section": "Piyasalar", + "macro_stocks_commodity_perps": "Stocks & commodities", + "macro_pill_stocks": "Hisse Senetleri", + "macro_pill_commodities": "Emtialar", + "rwa_pill_commodities": "Emtialar", + "rwa_pill_stocks": "Hisse Senetleri", + "rwa_pill_forex": "Forex", + "crypto_perps_section": "Sürekli Vadeli", "predictions": "Tahminler", "no_results": "Sonuç bulunamadı", + "popular": "Popüler", "sites": "Siteler", "popular_sites": "Popüler siteler", "search_sites": "Siteleri ara", @@ -8698,6 +8847,14 @@ "empty_search_result_state": { "title": "Token bulunamadı", "description": "Bu tokenı bulamadık" + }, + "tabs": { + "now": "Şimdi", + "macro": "Makro", + "rwas": "RWAs", + "crypto": "Kripto", + "sports": "Spor", + "dapps": "Siteler" } }, "ota_update_modal": { @@ -8715,7 +8872,9 @@ }, "common": { "cancel": "İptal", - "continue": "Devam et" + "continue": "Devam et", + "learn_more": "Daha fazla bilgi edin", + "try_again": "Tekrar dene" }, "connecting": { "title": "{{device}} cihazınız bağlanıyor...", @@ -8761,6 +8920,7 @@ "bluetooth_off": "Cihazınıza bağlanmak için lütfen Bluetooth'u açın", "bluetooth_scan_failed": "Cihazlar taranamadı. Lütfen tekrar deneyin", "bluetooth_connection_failed": "Cihazınıza bağlanılamadı. Lütfen tekrar deneyin", + "camera_permission_denied": "Camera permission is required to scan QR codes. Please enable it in your device settings", "not_supported": "Bu işlem desteklenmiyor", "unknown_error": "{{device}} cihazınızda bu hesap için Gizli Kurtarma İfadesi veya parola kurulumunun yapıldığından emin olun" }, @@ -8784,8 +8944,35 @@ "location_permission_denied": "Konum İzni Gerekli", "nearby_devices_permission_denied": "Yakındaki Cihazlar İzni Gerekli", "scan_failed": "Tarama Başarısız Oldu", + "camera_permission_denied": "Camera Permission Required", "something_went_wrong": "Bir şeyler ters gitti" }, + "qr_scan_errors": { + "non_ur_qr_scanned": { + "pair": { + "title": "Scan wallet sync QR code", + "body": "Go back to your hardware wallet and find the option to share or export your accounts, choose MetaMask, then scan the QR code." + }, + "sign": { + "title": "Scan signature QR code", + "body": "Go back to your hardware wallet and approve the transaction, then scan the QR code it shows." + } + }, + "wrong_ur_type": { + "pair": { + "title": "Wrong QR code", + "body": "Go back to your hardware wallet, choose MetaMask as the wallet type, then generate and scan a new QR code." + }, + "sign": { + "title": "Wrong QR code", + "body": "Go back to your hardware wallet and approve the transaction, then scan the QR code it shows." + } + }, + "ur_decode_error": { + "title": "Couldn't read QR code", + "body": "Make sure the QR code is fully visible and well-lit, and try again. If the issue continues, you may need to update your hardware wallet's firmware." + } + }, "device_selection": { "title": "{{device}} cihazını seçin", "scanning": "Cihazlar taranıyor...", @@ -8803,6 +8990,14 @@ "cash": "Para", "cash_empty_description": "Henüz mUSD'niz yok. Ana sayfada Para kısmından stabil kripto paraları mUSD'ye dönüştürün.", "cash_empty_description_network_filter": "Bu ağda mUSD yok. mUSD'nizi görmek için ağ değiştirin.", + "cash_empty_state": { + "get_started": "Başlarken", + "earn_apy": "Earn {{percentage}}% APY" + }, + "cash_filled_state": { + "add": "Ekle", + "apy": "%{{percentage}} Yıllık Bileşik Getiri" + }, "tokens": "tokenlar", "perpetuals": "Sürekli Vadeli İşlemler", "predictions": "Tahminler", @@ -8832,5 +9027,8 @@ "unable_to_load": "{{section}} yüklenemiyor", "retry": "Tekrar Dene" } + }, + "sites": { + "popular": "Popüler" } } diff --git a/locales/languages/vi.json b/locales/languages/vi.json index 84d4fb82cbc..a3e14e0a16b 100644 --- a/locales/languages/vi.json +++ b/locales/languages/vi.json @@ -634,7 +634,8 @@ "trade": "Giao dịch", "settings": "Cài đặt", "rewards": "Phần thưởng", - "trending": "Khám phá" + "trending": "Khám phá", + "money": "Tài chính" }, "drawer": { "send_button": "Gửi", @@ -2446,6 +2447,12 @@ } } }, + "payment": { + "change_payment_method": "Change Payment Method", + "add_funds": "Nạp tiền", + "add": "Thêm", + "predict_balance": "Predict balance" + }, "order": { "available": "Khả dụng", "cashed_out": "Đã rút tiền", @@ -3314,6 +3321,8 @@ "invalid_number_range": "Số không hợp lệ. Hãy nhập một số từ 1 đến %{maxSafeChainId}", "hide_zero_balance_tokens_title": "Ẩn token không có số dư", "hide_zero_balance_tokens_desc": "Không hiển thị các Token không có số dư trong danh sách Token của bạn.", + "haptic_feedback_title": "Haptic feedback", + "haptic_feedback_desc": "Feel subtle vibrations for confirmations, errors, and interactions. Disable to reduce haptic stimuli.", "token_detection_title": "Tự động phát hiện token", "token_detection_description": "Chúng tôi sử dụng các API của bên thứ ba để phát hiện và hiển thị các Token mới được gửi đến ví của bạn. Hãy tắt nếu bạn không muốn ứng dụng lấy dữ liệu từ các dịch vụ đó.", "theme_button_text": "Thay đổi chủ đề", @@ -3457,7 +3466,7 @@ "title": "Headless buy", "loading": "Preparing the buy flow…", "no_session": "Headless session is no longer active.", - "cancel": "Cancel" + "cancel": "Hủy" } }, "request_feature": "Yêu cầu tính năng", @@ -3572,6 +3581,39 @@ "title": "Thẻ", "reset_onboarding_description": "Đặt lại trạng thái thiết lập Thẻ để bắt đầu lại quy trình thiết lập từ đầu.", "reset_onboarding_button": "Đặt lại trạng thái thiết lập" + }, + "haptics": { + "title": "Haptics", + "description": "Preview haptic feedback on a physical device. This bypasses in-app reduced-haptics and the remote kill switch so you can evaluate each pattern. Simulators often do not play haptics.", + "catalog_impacts_heading": "Catalog impact moments", + "notifications_heading": "Notification patterns", + "selection_heading": "Selection", + "notification_success_button": "Notification — Success", + "notification_error_button": "Notification — Error", + "notification_warning_button": "Notification — Warning", + "selection_button": "Selection", + "raw_impacts_heading": "Raw Expo impact styles", + "raw_impacts_desc": "Direct expo-haptics impact styles for comparison with the mapped catalog moments above.", + "impacts": { + "quick_amount_selection": "Impact — Quick amount selection", + "slider_tick": "Impact — Slider tick", + "edge_gesture_engage": "Impact — Edge gesture engage", + "page_navigation": "Impact — Page navigation", + "slider_grip": "Impact — Slider grip", + "tab_change": "Impact — Tab change", + "primary_cta": "Impact — Primary CTA", + "pull_to_refresh_engage": "Impact — Pull to refresh (engage)", + "pull_to_refresh": "Impact — Pull to refresh (release)", + "chart_crosshair": "Impact — Chart crosshair", + "follow_toggle": "Impact — Follow toggle" + }, + "raw_styles": { + "light": "Raw impact — Light", + "medium": "Raw impact — Medium", + "heavy": "Raw impact — Heavy", + "rigid": "Raw impact — Rigid", + "soft": "Raw impact — Soft" + } } }, "feature_flag_override": { @@ -6482,7 +6524,7 @@ "onboarding": { "step_progress": "Bước {{current}}/{{total}}", "title": "Nạp tiền", - "description": "Mua mUSD và bắt đầu nhận APY ngay hôm nay.", + "description": "Fund your account and start earning APY.", "add": "Thêm", "step2_title": "Nhận Thẻ MetaMask của bạn", "step2_description": "Chi tiêu số dư Tài chính của bạn trong khi vẫn sinh lời, ở bất kỳ đâu chấp nhận Mastercard.", @@ -6499,7 +6541,8 @@ "earnings": { "title": "Thu nhập", "lifetime": "Thu nhập trọn đời", - "projected": "Thu nhập dự kiến" + "projected": "Thu nhập dự kiến", + "info_label": "Earnings info" }, "how_it_works": { "title": "Cách hoạt động", @@ -6553,6 +6596,35 @@ "receive_external": "Receive from external wallet", "coming_soon": "Sắp ra mắt" }, + "more_sheet": { + "title": "Thêm", + "how_it_works": "Cách hoạt động", + "what_you_get": "Những gì bạn nhận được", + "contact_support": "Liên hệ bộ phận hỗ trợ" + }, + "transfer_sheet": { + "title": "Transfer money", + "between_accounts": "Between accounts", + "perps_account": "Perps account", + "predictions_account": "Predictions account", + "send_external": "Send to external address", + "withdraw_to_bank": "Withdraw to bank" + }, + "apy_tooltip": { + "title": "Annual Percentage Yield (APY)", + "paragraph_1": "Your Money account earns up to {{percentage}}% automatically.", + "paragraph_2": "The rate shown is an estimated Annual Percentage Yield (APY) based on current market conditions. APY is variable and may change due to various factors. The rate is generated by third-party DeFi platforms that deploy funds in blockchain protocols.", + "paragraph_3": "When you link your MetaMask Card, purchases will be deducted from this balance.", + "learn_more": "Tìm hiểu thêm" + }, + "earnings_tooltip": { + "title": "Thu nhập", + "lifetime_heading": "Thu nhập trọn đời", + "lifetime_body": "The total yield you've earned since opening your Money account.", + "projected_heading": "Thu nhập dự kiến", + "projected_body": "Dự đoán số tiền bạn sẽ nhận được trong một năm dựa trên số dư và lãi suất hiện tại.", + "disclaimer": "Projection assumes {{percentage}}% Annual Percentage Yield (APY) remains unchanged for 1 year. APY is variable and may change due to various factors. No guarantee of return." + }, "activity": { "title": "Hoạt động", "view_all": "Xem tất cả", @@ -6917,7 +6989,9 @@ "perps_deposit": "Nạp tiền", "perps_withdraw": "Rút tiền", "predict_deposit": "Nạp tiền Dự đoán", - "predict_withdraw": "Rút tiền" + "predict_withdraw": "Rút tiền", + "money_account_add_money": "Nạp tiền", + "money_account_transfer_money": "Transfer money" }, "sub_title": { "permit": "Trang web này muốn được cấp quyền chi tiêu token của bạn.", @@ -6937,7 +7011,7 @@ "transaction_fee": "Chúng tôi sẽ hoán đổi token của bạn sang USDC trên HyperCore, mạng được Hợp đồng vĩnh cửu sử dụng. Các nhà cung cấp dịch vụ hoán đổi có thể tính phí, nhưng MetaMask thì không." }, "predict_deposit": { - "transaction_fee": "Chúng tôi sẽ hoán đổi token của bạn sang USDC.e trên Polygon, mạng được thị trường Dự đoán sử dụng. Các nhà cung cấp dịch vụ hoán đổi có thể tính phí, nhưng MetaMask thì không." + "transaction_fee": "We'll swap your tokens for pUSD on Polygon, the network used by Predictions. Swap providers may charge a fee, but MetaMask won't." }, "perps_withdraw": { "transaction_fee": "MetaMask sẽ hoán đổi sang token mà bạn mong muốn. Không áp dụng phí MetaMask khi bạn hoán đổi sang mUSD." @@ -7033,7 +7107,8 @@ "custom_amount": { "buy_button": "Mua tiền mã hóa", "buy_predict": "Nạp tiền vào ví của bạn để sử dụng Dự đoán.", - "buy_perps": "Nạp tiền vào ví của bạn để sử dụng Hợp đồng vĩnh cửu." + "buy_perps": "Nạp tiền vào ví của bạn để sử dụng Hợp đồng vĩnh cửu.", + "projected_five_year_balance": "Projected 5-year balance:" }, "unlimited": "Không giới hạn", "all": "Tất cả", @@ -7113,6 +7188,7 @@ "receive_at": "Nhận tại", "recipient": "Người nhận", "select_recipient": "Chọn người nhận", + "select_account": "Chọn tài khoản", "external_account": "Tài khoản bên ngoài", "error_banner_description": "Lộ trình giao dịch này hiện không khả dụng. Hãy thử thay đổi số lượng, mạng hoặc token và chúng tôi sẽ tìm phương án tốt nhất.", "stock_token_error_banner_description": "Tuyến giao dịch này hiện không khả dụng. Hãy thử thay đổi số lượng, mạng hoặc token và chúng tôi sẽ tìm phương án tốt nhất.\n\nLưu ý, nếu bạn đang cố giao dịch Ondo Tokenised Stocks, bạn có thể bị hạn chế theo khu vực, ví dụ như tại Mỹ, Liên minh Châu Âu, Vương quốc Anh và Brazil.", @@ -7791,6 +7867,7 @@ "enable_card_button_label": "Kích hoạt thẻ", "enable_assets_button_label": "Kích hoạt tài sản", "spending_limit_warning": "Bạn sắp đạt đến hạn mức chi tiêu. Cập nhật để tránh bị từ chối.", + "spending_limit_available": "khả dụng", "logout": "Đăng xuất", "contact_support": "Liên hệ bộ phận hỗ trợ", "logout_confirmation_title": "Đăng xuất khỏi Thẻ?", @@ -7925,7 +8002,12 @@ "withdrawal_success": "Rút tiền thành công", "withdrawal_failed": "Rút tiền thất bại. Vui lòng thử lại.", "no_cashback": "Không có hoàn tiền khả dụng", - "loading_error": "Không thể tải thông tin hoàn tiền. Vui lòng thử lại." + "loading_error": "Không thể tải thông tin hoàn tiền. Vui lòng thử lại.", + "funding_required": { + "title": "Set up Linea funding", + "description": "You need at least one approved funding source on Linea before redeeming cashback.", + "confirm_button_label": "Set up funding" + } }, "change_asset": { "title": "Thay đổi token và mạng", @@ -8030,7 +8112,8 @@ "title": "Chọn phương thức thanh toán", "title_receive": "Chọn token nhận được", "no_gas": "Không có số dư token gốc để trả phí gas", - "not_supported": "Không được hỗ trợ" + "not_supported": "Không được hỗ trợ", + "crypto": "Crypto" }, "connection_removed_modal": { "title": "Kết nối đã bị xóa", @@ -8365,6 +8448,8 @@ "ends_date": "Kết thúc {{date}}", "ended_date": "Đã kết thúc {{date}}", "pill_up_next": "Sắp ra mắt", + "up_next": "Sắp tới", + "notify_me": "Thông báo cho tôi", "pill_active": "Đang diễn ra", "pill_complete": "Hoàn tất", "enter": "Tham gia", @@ -8378,7 +8463,9 @@ "geo_locked_toast_title": "Không khả dụng tại khu vực của bạn", "geo_locked_toast_description": "Chiến dịch này không khả dụng tại khu vực của bạn. Vui lòng quay lại sau để xem các chiến dịch mới.", "geo_locked_cta": "Kiểm tra điều kiện đủ", - "geo_loading": "Đang kiểm tra khu vực..." + "geo_loading": "Đang kiểm tra khu vực...", + "remind_me_success_toast": "We'll let you know when this campaign starts.", + "remind_me_save_error": "Could not save your reminder. Please try again." }, "campaign_mechanics": { "title": "Cơ chế" @@ -8507,6 +8594,46 @@ "cancel": "Hủy", "confirm": "Tôi hiểu" }, + "perps_trading_campaign": { + "title": "Perps Trading Competition", + "stats_title": "Stats", + "performance_title": "Performance", + "label_rank": "Rank", + "label_your_rank": "Your rank", + "label_pnl": "PnL", + "label_volume": "Volume", + "label_notional_volume": "Notional volume", + "label_margin": "Margin", + "label_margin_deployed": "Margin deployed", + "pending": "Pending", + "qualified": "Qualified", + "open_position_cta": "Open Position", + "leaderboard_title": "Leaderboard", + "leaderboard_total_participants": "{{count}} participants", + "last_updated": "Last updated: {{time}}", + "leaderboard_error_loading": "Failed to load leaderboard", + "leaderboard_error_loading_description": "Something went wrong while loading the leaderboard. Please try again.", + "leaderboard_not_yet_computed": "Leaderboard hasn't been computed yet. Check back soon.", + "leaderboard_powered_by_prefix": "Powered by ", + "leaderboard_hypertracker_brand": "HyperTracker", + "prize_pool_title": "Prize pool", + "prize_pool_current_label": "Current", + "prize_pool_next_label": "Next", + "prize_pool_volume_subtext": "{{current}} of {{target}} volume", + "prize_pool_max_tier_subtext": "{{maxThreshold}}+ volume — all milestones reached", + "prize_pool_max_badge": "Max", + "prize_pool_error_title": "Failed to load prize pool", + "prize_pool_error_description": "There was an error loading the prize pool. Please try again.", + "prize_pool_retry_button": "Retry", + "stats_notional_volume_threshold": "${{amount}} required", + "stats_qualified_title": "You're qualified", + "stats_qualified_description": "Keep trading to improve your PnL and climb the leaderboard before the competition ends.", + "stats_qualify_for_rank_title": "Qualify for this rank", + "stats_qualify_for_rank_description": "Trade {{notionalRemaining}} more in notional volume to become eligible.", + "stats_error_title": "Unable to load stats", + "stats_error_description": "We had a problem loading your stats. Please try again.", + "stats_retry": "Retry" + }, "campaigns_preview": { "title": "Chiến dịch", "coming_soon": "Sắp ra mắt", @@ -8661,6 +8788,8 @@ }, "trending": { "title": "Khám phá", + "crypto_movers": "Crypto movers", + "perps_movers": "Perps movers", "trending_tokens": "Xu hướng", "stocks": "Cổ phiếu", "price_change": "Biến động giá", @@ -8672,6 +8801,17 @@ "1_hour": "1 giờ", "5_minutes": "5 phút", "networks": "Mạng", + "ecosystems": "Ecosystems", + "ecosystems_subtitle": "Explore dApps, tokens, and NFTs across chains on MetaMask Portfolio", + "all_sports": "Popular sports", + "basketball": "Basketball", + "football": "Football", + "soccer": "Soccer", + "tennis": "Tennis", + "f1": "F1", + "golf": "Golf", + "all_sports_no_markets": "No open markets in this category yet. Check back soon or pick another sport above.", + "load_more": "Load more", "sort_by": "Sắp xếp theo", "volume": "Khối lượng", "market_cap": "Vốn hóa thị trường", @@ -8681,8 +8821,17 @@ "search_placeholder": "Tìm kiếm token, trang web, URL", "cancel": "Hủy", "perps": "Vĩnh cửu", + "rwa_perps_section": "Thị trường", + "macro_stocks_commodity_perps": "Stocks & commodities", + "macro_pill_stocks": "Cổ phiếu", + "macro_pill_commodities": "Hàng hóa", + "rwa_pill_commodities": "Hàng hóa", + "rwa_pill_stocks": "Cổ phiếu", + "rwa_pill_forex": "Ngoại hối", + "crypto_perps_section": "Vĩnh cửu", "predictions": "Dự đoán", "no_results": "Không tìm thấy kết quả", + "popular": "Phổ biến", "sites": "Trang web", "popular_sites": "Trang web phổ biến", "search_sites": "Tìm kiếm trang web", @@ -8698,6 +8847,14 @@ "empty_search_result_state": { "title": "Không tìm thấy token", "description": "Chúng tôi không thể tìm thấy token này" + }, + "tabs": { + "now": "Ngay bây giờ", + "macro": "Vĩ mô", + "rwas": "RWAs", + "crypto": "Crypto", + "sports": "Thể thao", + "dapps": "Trang web" } }, "ota_update_modal": { @@ -8715,7 +8872,9 @@ }, "common": { "cancel": "Hủy", - "continue": "Tiếp tục" + "continue": "Tiếp tục", + "learn_more": "Tìm hiểu thêm", + "try_again": "Thử lại" }, "connecting": { "title": "Đang kết nối {{device}} của bạn...", @@ -8761,6 +8920,7 @@ "bluetooth_off": "Vui lòng bật Bluetooth để kết nối với thiết bị của bạn", "bluetooth_scan_failed": "Không thể quét thiết bị. Vui lòng thử lại", "bluetooth_connection_failed": "Kết nối với thiết bị của bạn không thành công. Vui lòng thử lại", + "camera_permission_denied": "Camera permission is required to scan QR codes. Please enable it in your device settings", "not_supported": "Thao tác này không được hỗ trợ", "unknown_error": "Đảm bảo {{device}} của bạn đã được thiết lập với Cụm từ khôi phục bí mật hoặc cụm mật khẩu cho tài khoản này" }, @@ -8784,8 +8944,35 @@ "location_permission_denied": "Yêu cầu quyền truy cập Vị trí", "nearby_devices_permission_denied": "Yêu cầu quyền truy cập Thiết bị lân cận", "scan_failed": "Quét thất bại", + "camera_permission_denied": "Camera Permission Required", "something_went_wrong": "Đã xảy ra sự cố" }, + "qr_scan_errors": { + "non_ur_qr_scanned": { + "pair": { + "title": "Scan wallet sync QR code", + "body": "Go back to your hardware wallet and find the option to share or export your accounts, choose MetaMask, then scan the QR code." + }, + "sign": { + "title": "Scan signature QR code", + "body": "Go back to your hardware wallet and approve the transaction, then scan the QR code it shows." + } + }, + "wrong_ur_type": { + "pair": { + "title": "Wrong QR code", + "body": "Go back to your hardware wallet, choose MetaMask as the wallet type, then generate and scan a new QR code." + }, + "sign": { + "title": "Wrong QR code", + "body": "Go back to your hardware wallet and approve the transaction, then scan the QR code it shows." + } + }, + "ur_decode_error": { + "title": "Couldn't read QR code", + "body": "Make sure the QR code is fully visible and well-lit, and try again. If the issue continues, you may need to update your hardware wallet's firmware." + } + }, "device_selection": { "title": "Chọn {{device}}", "scanning": "Đang quét thiết bị...", @@ -8803,6 +8990,14 @@ "cash": "Tài chính", "cash_empty_description": "Bạn chưa có mUSD. Hãy chuyển đổi đồng ổn định sang mUSD từ mục Tài chính trên trang chủ.", "cash_empty_description_network_filter": "Không có mUSD trên mạng này. Hãy chuyển mạng để xem mUSD của bạn.", + "cash_empty_state": { + "get_started": "Bắt đầu", + "earn_apy": "Earn {{percentage}}% APY" + }, + "cash_filled_state": { + "add": "Thêm", + "apy": "APY {{percentage}}%" + }, "tokens": "Token", "perpetuals": "Hợp đồng vĩnh cửu", "predictions": "Dự đoán", @@ -8832,5 +9027,8 @@ "unable_to_load": "Không thể tải {{section}}", "retry": "Thử lại" } + }, + "sites": { + "popular": "Phổ biến" } } diff --git a/locales/languages/zh.json b/locales/languages/zh.json index d5a5f8e1996..bee8f24db28 100644 --- a/locales/languages/zh.json +++ b/locales/languages/zh.json @@ -634,7 +634,8 @@ "trade": "交易", "settings": "设置", "rewards": "奖励", - "trending": "探索" + "trending": "探索", + "money": "Money" }, "drawer": { "send_button": "发送", @@ -2446,6 +2447,12 @@ } } }, + "payment": { + "change_payment_method": "Change Payment Method", + "add_funds": "充值", + "add": "添加", + "predict_balance": "Predict balance" + }, "order": { "available": "可用", "cashed_out": "已提现", @@ -3314,6 +3321,8 @@ "invalid_number_range": "无效数字。输入1至%{maxSafeChainId}之间的数字", "hide_zero_balance_tokens_title": "隐藏没有余额的代币", "hide_zero_balance_tokens_desc": "防止没有余额的代币显示在您的代币列表中。", + "haptic_feedback_title": "Haptic feedback", + "haptic_feedback_desc": "Feel subtle vibrations for confirmations, errors, and interactions. Disable to reduce haptic stimuli.", "token_detection_title": "自动检测代币", "token_detection_description": "我们使用第三方 API 来检测和显示发送到您钱包的新代币。如果您不希望该应用程序从这些服务中获取数据,请关闭。", "theme_button_text": "更改主题", @@ -3457,7 +3466,7 @@ "title": "Headless buy", "loading": "Preparing the buy flow…", "no_session": "Headless session is no longer active.", - "cancel": "Cancel" + "cancel": "取消" } }, "request_feature": "功能请求", @@ -3572,6 +3581,39 @@ "title": "卡", "reset_onboarding_description": "重设卡绑定状态,使绑定流程从头开始。", "reset_onboarding_button": "重设绑定状态" + }, + "haptics": { + "title": "Haptics", + "description": "Preview haptic feedback on a physical device. This bypasses in-app reduced-haptics and the remote kill switch so you can evaluate each pattern. Simulators often do not play haptics.", + "catalog_impacts_heading": "Catalog impact moments", + "notifications_heading": "Notification patterns", + "selection_heading": "Selection", + "notification_success_button": "Notification — Success", + "notification_error_button": "Notification — Error", + "notification_warning_button": "Notification — Warning", + "selection_button": "Selection", + "raw_impacts_heading": "Raw Expo impact styles", + "raw_impacts_desc": "Direct expo-haptics impact styles for comparison with the mapped catalog moments above.", + "impacts": { + "quick_amount_selection": "Impact — Quick amount selection", + "slider_tick": "Impact — Slider tick", + "edge_gesture_engage": "Impact — Edge gesture engage", + "page_navigation": "Impact — Page navigation", + "slider_grip": "Impact — Slider grip", + "tab_change": "Impact — Tab change", + "primary_cta": "Impact — Primary CTA", + "pull_to_refresh_engage": "Impact — Pull to refresh (engage)", + "pull_to_refresh": "Impact — Pull to refresh (release)", + "chart_crosshair": "Impact — Chart crosshair", + "follow_toggle": "Impact — Follow toggle" + }, + "raw_styles": { + "light": "Raw impact — Light", + "medium": "Raw impact — Medium", + "heavy": "Raw impact — Heavy", + "rigid": "Raw impact — Rigid", + "soft": "Raw impact — Soft" + } } }, "feature_flag_override": { @@ -6482,7 +6524,7 @@ "onboarding": { "step_progress": "第 {{current}} 步(共 {{total}} 步)", "title": "存入资金", - "description": "购买 mUSD,即日起赚取年化收益。", + "description": "Fund your account and start earning APY.", "add": "添加", "step2_title": "获取您的 MetaMask 卡", "step2_description": "在任何受理万事达卡的地方,使用您的 Money 账户余额消费,同时赚取收益。", @@ -6499,7 +6541,8 @@ "earnings": { "title": "收益", "lifetime": "累计收益", - "projected": "预估收益" + "projected": "预估收益", + "info_label": "Earnings info" }, "how_it_works": { "title": "如何运行", @@ -6553,6 +6596,35 @@ "receive_external": "Receive from external wallet", "coming_soon": "即将推出" }, + "more_sheet": { + "title": "展开", + "how_it_works": "如何运行", + "what_you_get": "您将获得", + "contact_support": "联系支持团队" + }, + "transfer_sheet": { + "title": "Transfer money", + "between_accounts": "Between accounts", + "perps_account": "Perps account", + "predictions_account": "Predictions account", + "send_external": "Send to external address", + "withdraw_to_bank": "Withdraw to bank" + }, + "apy_tooltip": { + "title": "Annual Percentage Yield (APY)", + "paragraph_1": "Your Money account earns up to {{percentage}}% automatically.", + "paragraph_2": "The rate shown is an estimated Annual Percentage Yield (APY) based on current market conditions. APY is variable and may change due to various factors. The rate is generated by third-party DeFi platforms that deploy funds in blockchain protocols.", + "paragraph_3": "When you link your MetaMask Card, purchases will be deducted from this balance.", + "learn_more": "了解详情" + }, + "earnings_tooltip": { + "title": "收益", + "lifetime_heading": "累计收益", + "lifetime_body": "The total yield you've earned since opening your Money account.", + "projected_heading": "预估收益", + "projected_body": "基于您当前余额及利率,推算出的全年预期收益。", + "disclaimer": "Projection assumes {{percentage}}% Annual Percentage Yield (APY) remains unchanged for 1 year. APY is variable and may change due to various factors. No guarantee of return." + }, "activity": { "title": "活动", "view_all": "查看全部", @@ -6917,7 +6989,9 @@ "perps_deposit": "充值", "perps_withdraw": "提取", "predict_deposit": "存入预测资金", - "predict_withdraw": "提取" + "predict_withdraw": "提取", + "money_account_add_money": "存入资金", + "money_account_transfer_money": "Transfer money" }, "sub_title": { "permit": "该网站想获得花费您的代币的许可。", @@ -6937,7 +7011,7 @@ "transaction_fee": "我们将在永续合约所用的 HyperCore 网络上将您的代币兑换为 USDC。兑换提供商可能收取费用,但 MetaMask 不另收费。" }, "predict_deposit": { - "transaction_fee": "我们将在预测所使用的 Polygon 网络上将您的代币兑换为 USDC.e。兑换提供商可能收取费用,但 MetaMask 不收取费用。" + "transaction_fee": "We'll swap your tokens for pUSD on Polygon, the network used by Predictions. Swap providers may charge a fee, but MetaMask won't." }, "perps_withdraw": { "transaction_fee": "MetaMask 将为您兑换为您想要的代币。兑换至 mUSD 时,MetaMask 不收取任何费用。" @@ -7033,7 +7107,8 @@ "custom_amount": { "buy_button": "购买加密货币", "buy_predict": "为钱包充值以使用预测服务。", - "buy_perps": "为钱包充值以使用永续合约。" + "buy_perps": "为钱包充值以使用永续合约。", + "projected_five_year_balance": "Projected 5-year balance:" }, "unlimited": "无限制", "all": "所有", @@ -7113,6 +7188,7 @@ "receive_at": "接收地址:", "recipient": "接收者", "select_recipient": "选择接收方", + "select_account": "选择账户", "external_account": "外部账户", "error_banner_description": "目前该交易路线不可用。请调整交易金额、切换网络或更换代币类型,我们将为您自动匹配最优通道。", "stock_token_error_banner_description": "此交易路径当前不可用。请尝试调整金额、网络或代币,我们将为您找到最佳方案。\n\n请注意,如果您想要交易 Ondo 代币化股票,可能会受到地域限制(例如美国、欧盟、英国和巴西等地区)。", @@ -7791,6 +7867,7 @@ "enable_card_button_label": "启用卡", "enable_assets_button_label": "启用资产", "spending_limit_warning": "您已接近消费限额。请及时更新以避免交易被拒。", + "spending_limit_available": "可用", "logout": "退出登录", "contact_support": "联系支持团队", "logout_confirmation_title": "确定要退出卡账户吗?", @@ -7925,7 +8002,12 @@ "withdrawal_success": "提现成功完成", "withdrawal_failed": "提现失败。请重试。", "no_cashback": "无可用返现", - "loading_error": "加载返现失败。请重试。" + "loading_error": "加载返现失败。请重试。", + "funding_required": { + "title": "Set up Linea funding", + "description": "You need at least one approved funding source on Linea before redeeming cashback.", + "confirm_button_label": "Set up funding" + } }, "change_asset": { "title": "更改代币和网络", @@ -8030,7 +8112,8 @@ "title": "选择付款方式", "title_receive": "选择收款代币", "no_gas": "无支付燃料所需的原生代币余额", - "not_supported": "不支持" + "not_supported": "不支持", + "crypto": "加密货币" }, "connection_removed_modal": { "title": "连接已删除", @@ -8365,6 +8448,8 @@ "ends_date": "结束于 {{date}}", "ended_date": "结束时间:{{date}}", "pill_up_next": "即将推出", + "up_next": "即将到来", + "notify_me": "通知我", "pill_active": "进行中", "pill_complete": "完成", "enter": "参加", @@ -8378,7 +8463,9 @@ "geo_locked_toast_title": "您所在区域不可用", "geo_locked_toast_description": "该活动在您所在地区暂未开放。请稍后回来查看新活动。", "geo_locked_cta": "检查资格", - "geo_loading": "正在检查地区……" + "geo_loading": "正在检查地区……", + "remind_me_success_toast": "We'll let you know when this campaign starts.", + "remind_me_save_error": "Could not save your reminder. Please try again." }, "campaign_mechanics": { "title": "机制" @@ -8507,6 +8594,46 @@ "cancel": "取消", "confirm": "我理解" }, + "perps_trading_campaign": { + "title": "Perps Trading Competition", + "stats_title": "Stats", + "performance_title": "Performance", + "label_rank": "Rank", + "label_your_rank": "Your rank", + "label_pnl": "PnL", + "label_volume": "Volume", + "label_notional_volume": "Notional volume", + "label_margin": "Margin", + "label_margin_deployed": "Margin deployed", + "pending": "Pending", + "qualified": "Qualified", + "open_position_cta": "Open Position", + "leaderboard_title": "Leaderboard", + "leaderboard_total_participants": "{{count}} participants", + "last_updated": "Last updated: {{time}}", + "leaderboard_error_loading": "Failed to load leaderboard", + "leaderboard_error_loading_description": "Something went wrong while loading the leaderboard. Please try again.", + "leaderboard_not_yet_computed": "Leaderboard hasn't been computed yet. Check back soon.", + "leaderboard_powered_by_prefix": "Powered by ", + "leaderboard_hypertracker_brand": "HyperTracker", + "prize_pool_title": "Prize pool", + "prize_pool_current_label": "Current", + "prize_pool_next_label": "Next", + "prize_pool_volume_subtext": "{{current}} of {{target}} volume", + "prize_pool_max_tier_subtext": "{{maxThreshold}}+ volume — all milestones reached", + "prize_pool_max_badge": "Max", + "prize_pool_error_title": "Failed to load prize pool", + "prize_pool_error_description": "There was an error loading the prize pool. Please try again.", + "prize_pool_retry_button": "Retry", + "stats_notional_volume_threshold": "${{amount}} required", + "stats_qualified_title": "You're qualified", + "stats_qualified_description": "Keep trading to improve your PnL and climb the leaderboard before the competition ends.", + "stats_qualify_for_rank_title": "Qualify for this rank", + "stats_qualify_for_rank_description": "Trade {{notionalRemaining}} more in notional volume to become eligible.", + "stats_error_title": "Unable to load stats", + "stats_error_description": "We had a problem loading your stats. Please try again.", + "stats_retry": "Retry" + }, "campaigns_preview": { "title": "活动", "coming_soon": "即将推出", @@ -8661,7 +8788,9 @@ }, "trending": { "title": "探索", - "trending_tokens": "热门", + "crypto_movers": "Crypto movers", + "perps_movers": "Perps movers", + "trending_tokens": "趋势", "stocks": "股票", "price_change": "价格变化", "all_networks": "所有网络", @@ -8672,6 +8801,17 @@ "1_hour": "1 小时", "5_minutes": "5 分钟", "networks": "网络", + "ecosystems": "Ecosystems", + "ecosystems_subtitle": "Explore dApps, tokens, and NFTs across chains on MetaMask Portfolio", + "all_sports": "Popular sports", + "basketball": "Basketball", + "football": "Football", + "soccer": "Soccer", + "tennis": "Tennis", + "f1": "F1", + "golf": "Golf", + "all_sports_no_markets": "No open markets in this category yet. Check back soon or pick another sport above.", + "load_more": "Load more", "sort_by": "排序方式", "volume": "交易量", "market_cap": "市值", @@ -8681,8 +8821,17 @@ "search_placeholder": "搜索代币、网站、URL", "cancel": "取消", "perps": "永续合约", + "rwa_perps_section": "市场", + "macro_stocks_commodity_perps": "Stocks & commodities", + "macro_pill_stocks": "股票", + "macro_pill_commodities": "大宗商品", + "rwa_pill_commodities": "大宗商品", + "rwa_pill_stocks": "股票", + "rwa_pill_forex": "外汇", + "crypto_perps_section": "永续合约", "predictions": "预测", "no_results": "未找到结果", + "popular": "热门", "sites": "网站", "popular_sites": "热门网站", "search_sites": "搜索网站", @@ -8698,6 +8847,14 @@ "empty_search_result_state": { "title": "找不到代币", "description": "我们找不到此代币" + }, + "tabs": { + "now": "立即", + "macro": "宏观", + "rwas": "RWAs", + "crypto": "加密货币", + "sports": "体育", + "dapps": "网站" } }, "ota_update_modal": { @@ -8715,7 +8872,9 @@ }, "common": { "cancel": "取消", - "continue": "继续" + "continue": "继续", + "learn_more": "了解详情", + "try_again": "请重试" }, "connecting": { "title": "正在连接您的 {{device}}……", @@ -8761,6 +8920,7 @@ "bluetooth_off": "请开启蓝牙以连接到您的设备", "bluetooth_scan_failed": "扫描设备失败。请重试", "bluetooth_connection_failed": "连接您的设备失败。请重试", + "camera_permission_denied": "Camera permission is required to scan QR codes. Please enable it in your device settings", "not_supported": "不支持此操作", "unknown_error": "确保您的 {{device}} 已使用此账户的私钥助记词或密语进行设置" }, @@ -8784,8 +8944,35 @@ "location_permission_denied": "需要位置许可", "nearby_devices_permission_denied": "需要附近设备许可", "scan_failed": "扫描失败", + "camera_permission_denied": "Camera Permission Required", "something_went_wrong": "出错了......" }, + "qr_scan_errors": { + "non_ur_qr_scanned": { + "pair": { + "title": "Scan wallet sync QR code", + "body": "Go back to your hardware wallet and find the option to share or export your accounts, choose MetaMask, then scan the QR code." + }, + "sign": { + "title": "Scan signature QR code", + "body": "Go back to your hardware wallet and approve the transaction, then scan the QR code it shows." + } + }, + "wrong_ur_type": { + "pair": { + "title": "Wrong QR code", + "body": "Go back to your hardware wallet, choose MetaMask as the wallet type, then generate and scan a new QR code." + }, + "sign": { + "title": "Wrong QR code", + "body": "Go back to your hardware wallet and approve the transaction, then scan the QR code it shows." + } + }, + "ur_decode_error": { + "title": "Couldn't read QR code", + "body": "Make sure the QR code is fully visible and well-lit, and try again. If the issue continues, you may need to update your hardware wallet's firmware." + } + }, "device_selection": { "title": "选择 {{device}}", "scanning": "正在扫描设备……", @@ -8803,6 +8990,14 @@ "cash": "Money", "cash_empty_description": "您目前尚未持有任何 mUSD。请从首页的“资金”部分将稳定币兑换为 mUSD。", "cash_empty_description_network_filter": "此网络中没有 mUSD。请切换网络以查看您的 mUSD。", + "cash_empty_state": { + "get_started": "开始", + "earn_apy": "Earn {{percentage}}% APY" + }, + "cash_filled_state": { + "add": "添加", + "apy": "{{percentage}}% 年化收益率" + }, "tokens": "代币", "perpetuals": "永续合约", "predictions": "预测", @@ -8818,7 +9013,7 @@ }, "defi": "DeFi", "nfts": "NFT", - "trending_tokens": "热门", + "trending_tokens": "趋势", "trending_perpetuals": "热门永续合约", "trending_predictions": "热门预测", "import_nfts": "导入 NFT", @@ -8832,5 +9027,8 @@ "unable_to_load": "无法加载 {{section}}", "retry": "重试" } + }, + "sites": { + "popular": "热门" } } diff --git a/package.json b/package.json index 9cbbcd91189..2aa61ed46e5 100644 --- a/package.json +++ b/package.json @@ -642,6 +642,7 @@ "eslint-config-prettier": "^8.1.0", "eslint-import-resolver-typescript": "^3.6.3", "eslint-plugin-import-x": "^0.5.1", + "eslint-plugin-jest": "^29.14.0", "eslint-plugin-jsdoc": "43.0.7", "eslint-plugin-prettier": "^3.3.1", "eslint-plugin-promise": "^7.2.1", diff --git a/scripts/lib/validate-value.js b/scripts/lib/validate-value.js new file mode 100644 index 00000000000..aca4abbd1db --- /dev/null +++ b/scripts/lib/validate-value.js @@ -0,0 +1,187 @@ +/** + * Shared hygiene checks for CI-injected values (GitHub secrets and builds.yml env values). + * + * The goal is to catch operator mistakes (trailing newline, Windows line endings, + * invisible characters pasted from rich text editors, etc.) at build time, before + * malformed values end up baked into a production binary. + * + * Intentionally format-agnostic: it does not try to understand whether a value + * is a URL, base64 blob, JWT, etc. It only enforces generic hygiene. + * + * Usage: + * const { checkValue } = require('./lib/validate-value'); + * const issues = checkValue('MM_SENTRY_DSN', value); + * // issues is an array; empty => value is clean. + * + * Output contract: issue messages MUST NOT include the value itself or any + * substring of it. Only the length, offsets, and character code points are + * safe to surface. + */ + +/* global Buffer */ + +// eslint-disable-next-line no-misleading-character-class -- intentional set of invisible code points (ZWSP/ZWNJ/ZWJ/BOM) +const ZERO_WIDTH_CHARS = /[\u200B\u200C\u200D\uFEFF]/; + +// C0/C1 control chars, excluding tab (\u0009), line feed (\u000A), and +// carriage return (\u000D). CR is reported separately with a friendlier +// message. LF is allowed mid-value for multi-line secrets (PEM keys, base64). +/* eslint-disable no-control-regex -- intentionally matches control characters to flag them */ +const CONTROL_CHARS = + /[\u0001-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/; +/* eslint-enable no-control-regex */ + +function formatCodePoint(ch) { + return `U+${ch.charCodeAt(0).toString(16).padStart(4, '0').toUpperCase()}`; +} + +/** + * @param {string} name - Identifier to report in violations (e.g. secret name or env key). + * @param {unknown} value - The value to check. Non-string values are coerced via String(). + * @param {object} [options] + * @param {boolean} [options.allowEmpty=false] - If true, an empty string is not a violation. + * Whitespace-only strings still fail even when allowEmpty is true (they indicate a typo). + * @returns {{ code: string, message: string }[]} - One entry per distinct violation; empty array means clean. + */ +function checkValue(name, value, options = {}) { + const { allowEmpty = false } = options; + const violations = []; + + /** + * `missing`: the value is `undefined` or `null`. For secrets, this means the + * referenced GitHub Environment secret was never set (or the Environment + * itself is misconfigured). For YAML env entries, it usually means a key + * like `FOO:` was written with no value, which js-yaml parses as `null`. + * Short-circuits: nothing else can be checked without a value. + */ + if (value === undefined || value === null) { + violations.push({ + code: 'missing', + message: `${name}: value is null or not defined`, + }); + return violations; + } + + const str = String(value); + const len = Buffer.byteLength(str, 'utf8'); + + /** + * `empty`: the value is the empty string `""`. Skipped when the caller + * passes `{ allowEmpty: true }`, used for intentionally-empty YAML entries + * such as optional allowlists (e.g. `MM_PERPS_HIP3_ALLOWLIST_MARKETS: ''`). + * Short-circuits: the remaining checks don't apply to an empty string. + */ + if (str === '') { + if (!allowEmpty) { + violations.push({ + code: 'empty', + message: `${name}: value is an empty string`, + }); + } + return violations; + } + + /** + * `whitespace_only`: the value is non-empty but contains nothing except + * whitespace. Almost always a typo (e.g. someone pasted a single space + * into the GitHub Secret UI). Fails even with `allowEmpty: true`, because + * "intentionally empty" should be `""`, not `" "`. + * Short-circuits: the value has no meaningful content to inspect further. + */ + if (str.trim() === '') { + violations.push({ + code: 'whitespace_only', + message: `${name}: value is whitespace-only (${len} bytes)`, + }); + return violations; + } + + /** + * `leading_whitespace`: the value begins with whitespace (space, tab, LF, + * etc.). Accidental leading whitespace breaks URL parsing, base64 decoding, + * and token comparisons — almost never intentional. + */ + if (/^\s/.test(str)) { + violations.push({ + code: 'leading_whitespace', + message: `${name}: value has leading whitespace (${len} bytes total)`, + }); + } + + /** + * `trailing_whitespace`: the value ends with whitespace. This is the + * single most common real-world paste mistake — e.g. copying a token from + * a terminal or editor ends up including the trailing `\n`. It's the + * specific failure mode this entire module was built to catch. + */ + if (/\s$/.test(str)) { + violations.push({ + code: 'trailing_whitespace', + message: `${name}: value has trailing whitespace (${len} bytes total); a trailing newline pasted from a terminal or editor is the most common cause`, + }); + } + + /** + * `carriage_return`: the value contains `\r` anywhere. CR is not part of + * the base64 alphabet, PEM uses LF, and no sanctioned secret format needs + * it — its presence is essentially always an artifact of Windows CRLF line + * endings surviving a copy-paste. Reported separately from `control_chars` + * so the remediation message can specifically call out Windows endings. + */ + const crIndex = str.indexOf('\r'); + if (crIndex !== -1) { + violations.push({ + code: 'carriage_return', + message: `${name}: value contains a carriage return (\\r) at offset ${crIndex}/${len}; strip Windows line endings before saving the secret`, + }); + } + + /** + * `nul_byte`: the value contains `\u0000`. Never legitimate in any secret + * format we use. A NUL byte can terminate strings prematurely in C-based + * tooling and is a classic source of silent truncation bugs. + */ + const nulIndex = str.indexOf('\u0000'); + if (nulIndex !== -1) { + violations.push({ + code: 'nul_byte', + message: `${name}: value contains a NUL byte at offset ${nulIndex}/${len}`, + }); + } + + /** + * `control_chars`: the value contains any other C0/C1 control character + * (range defined in CONTROL_CHARS — excludes tab, LF, and CR, which are + * allowed or reported separately). Hits here usually indicate non-text + * binary data was pasted as if it were a string, or a stray escape + * sequence. The message includes the specific code point (e.g. U+0007) + * so operators can identify what was pasted. + */ + const ctrlMatch = CONTROL_CHARS.exec(str); + if (ctrlMatch) { + violations.push({ + code: 'control_chars', + message: `${name}: value contains control character ${formatCodePoint(ctrlMatch[0])} at offset ${ctrlMatch.index}/${len}`, + }); + } + + /** + * `zero_width`: the value contains an invisible Unicode character — + * zero-width space (U+200B), zero-width non-joiner (U+200C), zero-width + * joiner (U+200D), or byte-order mark (U+FEFF). These are introduced when + * copying from rich-text sources (Google Docs, Slack, Notion, Confluence) + * and are invisible to the human eye but break exact-match comparisons + * and break formats like base64 that only accept a strict alphabet. + */ + const zwMatch = ZERO_WIDTH_CHARS.exec(str); + if (zwMatch) { + violations.push({ + code: 'zero_width', + message: `${name}: value contains invisible character ${formatCodePoint(zwMatch[0])} at offset ${zwMatch.index}/${len}; likely pasted from a rich text source`, + }); + } + + return violations; +} + +module.exports = { checkValue }; diff --git a/scripts/lib/validate-value.test.js b/scripts/lib/validate-value.test.js new file mode 100644 index 00000000000..ebb47a272bf --- /dev/null +++ b/scripts/lib/validate-value.test.js @@ -0,0 +1,145 @@ +const { checkValue } = require('./validate-value'); + +const codes = (issues) => issues.map((i) => i.code); + +describe('checkValue', () => { + describe('happy path', () => { + it('returns no issues for a typical single-line secret', () => { + expect(checkValue('X', 'key_live_abc123xyz')).toEqual([]); + }); + + it('returns no issues for a multi-line base64 blob (embedded \\n mid-value is allowed)', () => { + const b64 = 'eyJhcGlLZXkiOiJhYmMifQ==\nsecond-line-content=='; + expect(checkValue('GOOGLE_SERVICES_B64_IOS', b64)).toEqual([]); + }); + + it('coerces non-string values via String()', () => { + expect(checkValue('N', 42)).toEqual([]); + expect(checkValue('B', true)).toEqual([]); + }); + }); + + describe('missing / empty', () => { + it('flags undefined', () => { + expect(codes(checkValue('X', undefined))).toEqual(['missing']); + }); + + it('flags null', () => { + expect(codes(checkValue('X', null))).toEqual(['missing']); + }); + + it('flags empty string by default', () => { + expect(codes(checkValue('X', ''))).toEqual(['empty']); + }); + + it('allows empty string when allowEmpty=true', () => { + expect(checkValue('X', '', { allowEmpty: true })).toEqual([]); + }); + + it('flags whitespace-only even when allowEmpty=true', () => { + expect(codes(checkValue('X', ' ', { allowEmpty: true }))).toEqual([ + 'whitespace_only', + ]); + }); + }); + + describe('leading / trailing whitespace', () => { + it('flags trailing newline (the classic paste mistake)', () => { + expect(codes(checkValue('X', 'value\n'))).toEqual([ + 'trailing_whitespace', + ]); + }); + + it('flags trailing space', () => { + expect(codes(checkValue('X', 'value '))).toEqual([ + 'trailing_whitespace', + ]); + }); + + it('flags trailing tab', () => { + expect(codes(checkValue('X', 'value\t'))).toEqual([ + 'trailing_whitespace', + ]); + }); + + it('flags leading space', () => { + expect(codes(checkValue('X', ' value'))).toEqual([ + 'leading_whitespace', + ]); + }); + + it('flags both leading and trailing whitespace in one pass', () => { + expect(codes(checkValue('X', ' value '))).toEqual([ + 'leading_whitespace', + 'trailing_whitespace', + ]); + }); + }); + + describe('control characters', () => { + it('flags any \\r (Windows line endings)', () => { + expect(codes(checkValue('X', 'abc\r\ndef'))).toContain('carriage_return'); + }); + + it('flags a standalone \\r mid-value', () => { + expect(codes(checkValue('X', 'abc\rdef'))).toContain('carriage_return'); + }); + + it('flags NUL bytes', () => { + expect(codes(checkValue('X', 'abc\u0000def'))).toContain('nul_byte'); + }); + + it('flags other C0 control characters', () => { + expect(codes(checkValue('X', 'abc\u0007def'))).toContain( + 'control_chars', + ); + }); + + it('flags DEL (U+007F)', () => { + expect(codes(checkValue('X', 'abc\u007Fdef'))).toContain( + 'control_chars', + ); + }); + + it('does NOT flag tab mid-value', () => { + expect(checkValue('X', 'abc\tdef')).toEqual([]); + }); + + it('does NOT flag LF mid-value (allowed for PEM / base64)', () => { + expect(checkValue('X', 'abc\ndef')).toEqual([]); + }); + }); + + describe('invisible characters', () => { + it('flags zero-width space', () => { + expect(codes(checkValue('X', 'abc\u200Bdef'))).toContain('zero_width'); + }); + + it('flags BOM', () => { + expect(codes(checkValue('X', '\uFEFFvalue'))).toEqual( + expect.arrayContaining(['leading_whitespace', 'zero_width']), + ); + }); + + it('flags zero-width joiner', () => { + expect(codes(checkValue('X', 'abc\u200Ddef'))).toContain('zero_width'); + }); + }); + + describe('message safety', () => { + it('never includes the value in the message', () => { + const secret = 'super-secret-token-xyz'; + const issues = checkValue('X', `${secret}\n`); + for (const { message } of issues) { + expect(message).not.toContain(secret); + } + }); + + it('includes the name, a byte length, and the code in the output', () => { + const [issue] = checkValue('MM_SENTRY_DSN', 'abc '); + expect(issue.code).toBe('trailing_whitespace'); + expect(issue.message).toContain('MM_SENTRY_DSN'); + expect(issue.message).toMatch(/\d+ bytes/); + }); + }); +}); diff --git a/scripts/validate-build-config.js b/scripts/validate-build-config.js index ec67e5bf9ed..7f9d82d40d2 100755 --- a/scripts/validate-build-config.js +++ b/scripts/validate-build-config.js @@ -6,9 +6,21 @@ const fs = require('fs'); const path = require('path'); const yaml = require('js-yaml'); +const { checkValue } = require('./lib/validate-value'); const BUILDS_PATH = path.join(__dirname, '../builds.yml'); +// Env keys in builds.yml that may legitimately be the empty string. Every +// other declared env key must have a non-empty value, so an accidental +// `PORTFOLIO_API_URL: ''` (or a botched YAML anchor merge) fails validation +// instead of shipping with a blank value. Adding to this list should be +// deliberate: the review on that PR is the gate for "yes, this key is +// intentionally optional." +const ENV_KEYS_ALLOWED_EMPTY = new Set([ + 'MM_PERPS_HIP3_ALLOWLIST_MARKETS', + 'MM_PERPS_HIP3_BLOCKLIST_MARKETS', +]); + function validate() { if (!fs.existsSync(BUILDS_PATH)) { console.error('❌ builds.yml not found'); @@ -46,6 +58,20 @@ function validate() { if (!build.github_environment) { errors.push(`${name}: missing github_environment`); } + + // Hygiene checks on env values: catch trailing whitespace, stray \r, + // invisible characters, accidental empties, etc. so operator typos fail + // CI before the build fans out. Strict by default; only the keys in + // ENV_KEYS_ALLOWED_EMPTY may be the empty string. Whitespace-only values + // always fail, since "intentionally empty" should be `''` not `' '`. + if (build.env && typeof build.env === 'object') { + for (const [envKey, envVal] of Object.entries(build.env)) { + const issues = checkValue(`${name}.env.${envKey}`, envVal, { + allowEmpty: ENV_KEYS_ALLOWED_EMPTY.has(envKey), + }); + issues.forEach((issue) => errors.push(issue.message)); + } + } }); if (errors.length > 0) { diff --git a/scripts/validate-secrets-from-config.js b/scripts/validate-secrets-from-config.js index bc48285aee3..2006b412563 100755 --- a/scripts/validate-secrets-from-config.js +++ b/scripts/validate-secrets-from-config.js @@ -1,66 +1,60 @@ #!/usr/bin/env node /** * Validates that all secrets required by the current build (from builds.yml) - * are defined and applied. Used in CI before "Set secrets" to fail fast and - * cancel the build if any required secret is missing or empty. + * are defined, non-empty, and free of common paste artifacts (trailing + * newlines, Windows line endings, invisible characters, etc.). Runs in CI + * before "Set secrets" to fail fast and prevent a malformed value from being + * written to GITHUB_ENV and baked into the build. * - * CI-agnostic: secrets are resolved from process.env by default. Pass - * SECRETS_JSON (a JSON object mapping secret name → value) to override, - * e.g. when the CI platform exposes all secrets as a single JSON blob rather - * than individual env vars. - * - * Fails when: - * - A secret is absent (not in env / not in SECRETS_JSON). - * - A secret is present but empty (defined but not set in the CI environment). + * Secrets are resolved from SECRETS_JSON when present (e.g. GitHub's + * toJSON(secrets) blob) and fall back to individual process.env lookups so + * the script works on any CI without extra wiring. * * Reads CONFIG_SECRETS (same format as set-secrets-from-config.js): * { "ENV_VAR_NAME": "SECRET_NAME", ... } * - * Exits 0 if every secret has a non-empty value; exits 1 otherwise. + * Exits 0 if every secret is present and clean; exits 1 otherwise, listing + * every offender in a single pass so operators can fix them all at once. + * + * Never logs secret values or any substring of them. Reports only names, + * byte lengths, offsets, and control-character code points. */ +const { checkValue } = require('./lib/validate-value'); + const secretsMapping = JSON.parse(process.env.CONFIG_SECRETS || '{}'); -// SECRETS_JSON lets callers pass all secrets as a JSON blob (e.g. GitHub's -// toJSON(secrets)); falls back to individual process.env lookups so the script -// works on any CI without extra wiring. const secretsSource = process.env.SECRETS_JSON ? JSON.parse(process.env.SECRETS_JSON) : process.env; -const missing = []; -const notApplied = []; + +const offenders = []; for (const [envVar, secretName] of Object.entries(secretsMapping)) { - const value = secretsSource[secretName]; - if (value === undefined || value === null) { - missing.push({ envVar, secretName }); - } else if (String(value).trim() === '') { - notApplied.push({ envVar, secretName }); + const issues = checkValue(secretName, secretsSource[secretName]); + if (issues.length > 0) { + offenders.push({ envVar, secretName, issues }); } } -const invalid = missing.length + notApplied.length; -if (invalid > 0) { +if (offenders.length > 0) { console.error( - 'Build validation failed: required secrets are not defined or not applied from GitHub.', + 'Build validation failed: one or more required secrets are missing or malformed.', ); - if (missing.length > 0) { - console.error('Not in environment:'); - missing.forEach(({ envVar, secretName }) => { - console.error(` - ${secretName} (for ${envVar})`); - }); - } - if (notApplied.length > 0) { - console.error('In environment but empty (not set in GitHub Environment):'); - notApplied.forEach(({ envVar, secretName }) => { - console.error(` - ${secretName} (for ${envVar})`); - }); + console.error(''); + for (const { envVar, secretName, issues } of offenders) { + for (const issue of issues) { + // GitHub Actions annotation so the error surfaces on the run summary. + console.error(`::error title=Malformed secret::${issue.message} (mapped to ${envVar})`); + } + console.error(` - ${secretName} (for ${envVar})`); } + console.error(''); console.error( - 'Ensure these secrets are set in the GitHub Environment for this build.', + 'Common cause: pasting a value with a trailing newline or Windows (CRLF) line endings. Re-paste the value carefully and ensure no surrounding whitespace.', ); process.exit(1); } console.log( - `All ${Object.keys(secretsMapping).length} required secret(s) are defined and applied.`, + `All ${Object.keys(secretsMapping).length} required secret(s) are defined and well-formed.`, ); diff --git a/tests/api-mocking/mock-e2e-allowlist.ts b/tests/api-mocking/mock-e2e-allowlist.ts index 9a850d5b951..13e69847e37 100644 --- a/tests/api-mocking/mock-e2e-allowlist.ts +++ b/tests/api-mocking/mock-e2e-allowlist.ts @@ -9,7 +9,6 @@ export const ALLOWLISTED_HOSTS = [ 'api.tenderly.co', 'rpc.tenderly.co', 'virtual.mainnet.rpc.tenderly.co', - 'testnet-rpc.monad.xyz', 'virtual.linea.rpc.tenderly.co', 'gamma-api.polymarket.com', '*.polymarket.com', @@ -18,8 +17,5 @@ export const ALLOWLISTED_HOSTS = [ export const ALLOWLISTED_URLS = [ // Temporarily allow existing live requests during migration - 'https://api.avax.network/ext/bc/C/rpc', - 'https://mainnet.era.zksync.io/', - 'https://rpc.atlantischain.network/', 'https://metamask.github.io/test-dapp/metamask-fox.svg', ]; diff --git a/tests/api-mocking/mock-responses/custom-rpc-provider-mocks.ts b/tests/api-mocking/mock-responses/custom-rpc-provider-mocks.ts index 1666319f5c9..4ee2deef8f8 100644 --- a/tests/api-mocking/mock-responses/custom-rpc-provider-mocks.ts +++ b/tests/api-mocking/mock-responses/custom-rpc-provider-mocks.ts @@ -16,11 +16,9 @@ const MOCK_BLOCK = { transactions: [], }; -// Method-specific mock responses for Ethereum mainnet RPC calls -const MOCK_RESPONSES: Record = { +// Shared method-specific mock responses applied to all proxied RPC hosts +const SHARED_MOCK_RESPONSES: Record = { eth_blockNumber: '0x178a60b', - net_version: '1', - eth_chainId: '0x1', eth_getBlockByNumber: MOCK_BLOCK, eth_call: '0x0000000000000000000000000000000000000000000000000000000000000000', @@ -30,11 +28,28 @@ const MOCK_RESPONSES: Record = { eth_estimateGas: '0x5208', }; -const LLAMARPC_URL = 'https://eth.llamarpc.com'; +// Per-host overrides ensure each mocked RPC reports the correct chain identity +// (returning Ethereum mainnet values for non-mainnet hosts can mask chain-switch bugs). +const PROXIED_RPC_CONFIGS: { + url: string; + chainId: string; + netVersion: string; +}[] = [ + { url: 'https://eth.llamarpc.com', chainId: '0x1', netVersion: '1' }, + { + url: 'https://rpc.atlantischain.network', + chainId: '0x53a', + netVersion: '1338', + }, +]; + +const findRpcConfig = (urlParam: string | null) => + PROXIED_RPC_CONFIGS.find((config) => urlParam?.startsWith(config.url)); /** - * TestSpecificMock that intercepts eth.llamarpc.com RPC calls - * through the mobile proxy, returning static responses per JSON-RPC method. + * TestSpecificMock that intercepts custom-RPC provider calls + * (eth.llamarpc.com, rpc.atlantischain.network) through the mobile + * proxy, returning static responses per JSON-RPC method. */ export const CUSTOM_RPC_PROVIDER_MOCKS: TestSpecificMock = async ( mockServer: Mockttp, @@ -43,15 +58,26 @@ export const CUSTOM_RPC_PROVIDER_MOCKS: TestSpecificMock = async ( .forPost('/proxy') .matching((request) => { const urlParam = new URL(request.url).searchParams.get('url'); - return Boolean(urlParam?.startsWith(LLAMARPC_URL)); + return Boolean(findRpcConfig(urlParam)); }) .asPriority(1000) .thenCallback(async (request) => { + const urlParam = new URL(request.url).searchParams.get('url'); + const rpcConfig = findRpcConfig(urlParam); + try { const bodyText = await request.body.getText(); const body = bodyText ? JSON.parse(bodyText) : undefined; const method = body?.method as string | undefined; - const result = method ? (MOCK_RESPONSES[method] ?? '0x') : '0x'; + + let result: unknown = '0x'; + if (method === 'eth_chainId') { + result = rpcConfig?.chainId ?? '0x1'; + } else if (method === 'net_version') { + result = rpcConfig?.netVersion ?? '1'; + } else if (method) { + result = SHARED_MOCK_RESPONSES[method] ?? '0x'; + } return { statusCode: 200, diff --git a/tests/api-mocking/mock-responses/defaults/rpc-endpoints.ts b/tests/api-mocking/mock-responses/defaults/rpc-endpoints.ts index ff807c358eb..93b11ef3525 100644 --- a/tests/api-mocking/mock-responses/defaults/rpc-endpoints.ts +++ b/tests/api-mocking/mock-responses/defaults/rpc-endpoints.ts @@ -24,5 +24,23 @@ export const DEFAULT_RPC_ENDPOINT_MOCKS: MockEventsObject = { result: '0x0', }, }, + { + urlEndpoint: 'https://api.avax.network/ext/bc/C/rpc', + responseCode: 200, + response: { + jsonrpc: '2.0', + id: 1, + result: '0x0', + }, + }, + { + urlEndpoint: 'https://mainnet.era.zksync.io/', + responseCode: 200, + response: { + jsonrpc: '2.0', + id: 1, + result: '0x0', + }, + }, ], }; diff --git a/tests/resources/blacklistURLs.json b/tests/resources/blacklistURLs.json index 506999428f0..7458fdfd8e1 100644 --- a/tests/resources/blacklistURLs.json +++ b/tests/resources/blacklistURLs.json @@ -22,8 +22,6 @@ ".*exp.host/--/api/v2/development-sessions/.*", ".*mainnet.infura.io/v3/.*", ".*staking.api.cx.metamask.io/v1/pooled-staking/eligibility.*", - ".*token.api.cx.metamask.io/tokens/1\\?.*", - ".*mainnet.era.zksync.io/.*", - ".*api.avax.network/ext/bc/C/rpc.*" + ".*token.api.cx.metamask.io/tokens/1\\?.*" ] } diff --git a/yarn.lock b/yarn.lock index 46323308d05..9d5d1b0345b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -28114,16 +28114,16 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-jest@npm:^29.0.1": - version: 29.14.0 - resolution: "eslint-plugin-jest@npm:29.14.0" +"eslint-plugin-jest@npm:^29.0.1, eslint-plugin-jest@npm:^29.14.0": + version: 29.15.1 + resolution: "eslint-plugin-jest@npm:29.15.1" dependencies: "@typescript-eslint/utils": "npm:^8.0.0" peerDependencies: "@typescript-eslint/eslint-plugin": ^8.0.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 jest: "*" - typescript: ">=4.8.4 <6.0.0" + typescript: ">=4.8.4 <7.0.0" peerDependenciesMeta: "@typescript-eslint/eslint-plugin": optional: true @@ -28131,7 +28131,7 @@ __metadata: optional: true typescript: optional: true - checksum: 10/657de023058e5ca55467ca4b04a5e70d12c4c5c19d56fa123fcb867c8e1bcb1b40dc66270e12206a8011794e15a86f75e7d3a7fb71bfc8648be58e1da7e7ffa5 + checksum: 10/436ae3c695f0dfe443e19d0ad94ede6e78adfaf47ba89db7046ac77b0e82127ad12ac2ed50846ef9f360d6c1bd5f0d2841b7fe5ff6e246eb069b0e85197a42fd languageName: node linkType: hard @@ -35956,6 +35956,7 @@ __metadata: eslint-config-prettier: "npm:^8.1.0" eslint-import-resolver-typescript: "npm:^3.6.3" eslint-plugin-import-x: "npm:^0.5.1" + eslint-plugin-jest: "npm:^29.14.0" eslint-plugin-jsdoc: "npm:43.0.7" eslint-plugin-prettier: "npm:^3.3.1" eslint-plugin-promise: "npm:^7.2.1"