diff --git a/app/components/UI/Rewards/Views/MusdCalculatorView.test.tsx b/app/components/UI/Rewards/Views/MusdCalculatorView.test.tsx
index 26bc16781ecc..a0abd10b486a 100644
--- a/app/components/UI/Rewards/Views/MusdCalculatorView.test.tsx
+++ b/app/components/UI/Rewards/Views/MusdCalculatorView.test.tsx
@@ -66,6 +66,13 @@ describe('MusdCalculatorView', () => {
expect(getByTestId('musd-calculator-tab')).toBeOnTheScreen();
});
+ it('wraps the calculator in a keyboard avoiding view', () => {
+ const { getByTestId } = render();
+ expect(
+ getByTestId('musd-calculator-keyboard-avoiding-view'),
+ ).toBeOnTheScreen();
+ });
+
it('tracks REWARDS_PAGE_VIEWED on mount with page_type musd_calculator', () => {
render();
diff --git a/app/components/UI/Rewards/Views/MusdCalculatorView.tsx b/app/components/UI/Rewards/Views/MusdCalculatorView.tsx
index 562b6c5301ba..ba05fe0f183e 100644
--- a/app/components/UI/Rewards/Views/MusdCalculatorView.tsx
+++ b/app/components/UI/Rewards/Views/MusdCalculatorView.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import { HeaderStandard } from '@metamask/design-system-react-native';
import { useNavigation } from '@react-navigation/native';
+import { KeyboardAvoidingView, Platform } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useTailwind } from '@metamask/design-system-twrnc-preset';
import ErrorBoundary from '../../../Views/ErrorBoundary';
@@ -25,7 +26,14 @@ const MusdCalculatorView: React.FC = () => {
onBack={() => navigation.goBack()}
backButtonProps={{ testID: 'header-back-button' }}
/>
-
+
+
+
);
diff --git a/app/components/UI/Rewards/components/Tabs/MusdCalculatorTab/MusdCalculatorTab.test.tsx b/app/components/UI/Rewards/components/Tabs/MusdCalculatorTab/MusdCalculatorTab.test.tsx
index 32e001cd22b5..6c81ffb2dd35 100644
--- a/app/components/UI/Rewards/components/Tabs/MusdCalculatorTab/MusdCalculatorTab.test.tsx
+++ b/app/components/UI/Rewards/components/Tabs/MusdCalculatorTab/MusdCalculatorTab.test.tsx
@@ -1,9 +1,47 @@
import React from 'react';
-import { render, fireEvent, waitFor } from '@testing-library/react-native';
+import { render, fireEvent, waitFor, act } from '@testing-library/react-native';
import MusdCalculatorTab from './MusdCalculatorTab';
import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics';
import { createMockUseAnalyticsHook } from '../../../../../../util/test/analyticsMock';
import { MetaMetricsEvents } from '../../../../../../core/Analytics';
+import { amountToPercent } from '../../../utils/musdCalculatorSlider';
+
+const mockPanGestureHandlers: {
+ onBegin?: (event: { x: number }) => void;
+ onUpdate?: (event: { x: number }) => void;
+ onFinalize?: (event: { x: number }) => void;
+} = {};
+
+jest.mock('react-native-gesture-handler', () => ({
+ GestureHandlerRootView: jest.requireActual('react-native').View,
+ GestureDetector: ({ children }: { children: React.ReactNode }) => children,
+ Gesture: {
+ Pan: jest.fn(() => ({
+ minDistance: jest.fn().mockReturnThis(),
+ onBegin: jest.fn(function (
+ this: unknown,
+ handler: (event: { x: number }) => void,
+ ) {
+ mockPanGestureHandlers.onBegin = handler;
+ return this;
+ }),
+ onUpdate: jest.fn(function (
+ this: unknown,
+ handler: (event: { x: number }) => void,
+ ) {
+ mockPanGestureHandlers.onUpdate = handler;
+ return this;
+ }),
+ onFinalize: jest.fn(function (
+ this: unknown,
+ handler: (event: { x: number }) => void,
+ ) {
+ mockPanGestureHandlers.onFinalize = handler;
+ return this;
+ }),
+ })),
+ },
+}));
const mockGoToSwaps = jest.fn();
const mockTrackEvent = jest.fn();
@@ -22,6 +60,13 @@ jest.mock('../../../../../../../locales/i18n', () => ({
strings: jest.fn((key: string) => key),
}));
+jest.mock('../../../../SimulationDetails/FiatDisplay/useFiatFormatter', () =>
+ jest.fn(
+ () => (value: { toNumber: () => number }) =>
+ `$${value.toNumber().toLocaleString('en-US')}`,
+ ),
+);
+
jest.mock('../../../../../../core/DeeplinkManager', () => ({
handleDeeplink: jest.fn(),
}));
@@ -43,6 +88,9 @@ jest.mock('../../../../../../util/theme', () => {
describe('MusdCalculatorTab', () => {
beforeEach(() => {
jest.clearAllMocks();
+ mockPanGestureHandlers.onBegin = undefined;
+ mockPanGestureHandlers.onUpdate = undefined;
+ mockPanGestureHandlers.onFinalize = undefined;
jest.mocked(useAnalytics).mockReturnValue(
createMockUseAnalyticsHook({
trackEvent: mockTrackEvent,
@@ -51,19 +99,19 @@ describe('MusdCalculatorTab', () => {
);
});
- it('renders all calculator UI elements', () => {
- const { getByText } = render();
-
- expect(getByText('rewards.musd.title')).toBeTruthy();
- expect(getByText('rewards.musd.description')).toBeTruthy();
- expect(getByText('rewards.musd.amount_label')).toBeTruthy();
- expect(getByText('rewards.musd.estimated_bonus')).toBeTruthy();
- expect(getByText('rewards.musd.initial_amount')).toBeTruthy();
- expect(getByText('rewards.musd.daily_bonus')).toBeTruthy();
- expect(getByText('rewards.musd.annualized_bonus')).toBeTruthy();
- expect(getByText('rewards.musd.disclaimer_brief')).toBeTruthy();
- expect(getByText('rewards.musd.buy_button')).toBeTruthy();
- expect(getByText('rewards.musd.swap_button')).toBeTruthy();
+ it('renders calculator layout and controls', () => {
+ const { getByText, getByTestId } = render();
+
+ expect(getByText('rewards.musd.hero_hold')).toBeOnTheScreen();
+ expect(getByText('rewards.musd.hero_earn')).toBeOnTheScreen();
+ expect(getByText('rewards.musd.slider_amount_label')).toBeOnTheScreen();
+ expect(getByText('rewards.musd.disclaimer_calculator')).toBeOnTheScreen();
+ expect(getByTestId('musd-slider-scale-min')).toHaveTextContent('$100');
+ expect(getByTestId('musd-slider-scale-mid')).toHaveTextContent('$1,000');
+ expect(getByTestId('musd-slider-scale-max')).toHaveTextContent('$10,000');
+ expect(getByText('rewards.musd.buy_button')).toBeOnTheScreen();
+ expect(getByText('rewards.musd.swap_button')).toBeOnTheScreen();
+ expect(getByTestId('musd-slider-track')).toBeOnTheScreen();
});
it('calls handleDeeplink and tracks buy_musd event when Buy button is pressed', () => {
@@ -71,8 +119,8 @@ describe('MusdCalculatorTab', () => {
'../../../../../../core/DeeplinkManager',
);
- const { getByText } = render();
- fireEvent.press(getByText('rewards.musd.buy_button'));
+ const { getByTestId } = render();
+ fireEvent.press(getByTestId('musd-buy-button'));
expect(handleDeeplink).toHaveBeenCalledWith({
uri: expect.stringContaining('link.metamask.io/buy'),
@@ -87,8 +135,8 @@ describe('MusdCalculatorTab', () => {
});
it('navigates to swap screen and tracks swap_to_musd event when Swap button is pressed', () => {
- const { getByText } = render();
- fireEvent.press(getByText('rewards.musd.swap_button'));
+ const { getByTestId } = render();
+ fireEvent.press(getByTestId('musd-swap-button'));
expect(mockGoToSwaps).toHaveBeenCalled();
expect(mockCreateEventBuilder).toHaveBeenCalledWith(
@@ -100,56 +148,141 @@ describe('MusdCalculatorTab', () => {
expect(mockTrackEvent).toHaveBeenCalled();
});
- it('updates input value when amount changes', () => {
- const { getByTestId } = render();
+ it('updates amount and earnings when the track is pressed', async () => {
+ const { getByTestId, getByText, queryByText } = render(
+ ,
+ );
+ const track = getByTestId('musd-slider-track');
+
+ fireEvent(track, 'layout', {
+ nativeEvent: { layout: { width: 300, height: 32, x: 0, y: 0 } },
+ });
- const input = getByTestId('musd-amount-input');
- fireEvent.changeText(input, '5000');
+ const locationX = (amountToPercent(5000) / 100) * 300;
+ fireEvent(track, 'pressIn', {
+ nativeEvent: { locationX },
+ });
- expect(input.props.value).toBe('5000');
+ await waitFor(() => {
+ expect(getByTestId('musd-slider-amount-display')).toHaveProp(
+ 'value',
+ '$5,000',
+ );
+ expect(
+ getByText(/rewards\.musd\.earnings_per_day_suffix/),
+ ).toBeOnTheScreen();
+ expect(
+ queryByText(/rewards\.musd\.earnings_per_month_suffix/),
+ ).toBeNull();
+ expect(getByText(/\$0\.41/)).toBeOnTheScreen();
+ expect(getByText(/\$150/)).toBeOnTheScreen();
+ });
});
- it('sanitizes non-numeric input', () => {
- const { getByTestId } = render();
+ it('allows editing the amount and updates earnings from typed values', async () => {
+ const { getByTestId, getByText } = render();
+ const amountInput = getByTestId('musd-slider-amount-display');
+
+ fireEvent(amountInput, 'focus');
+ fireEvent.changeText(amountInput, '5000');
+
+ await waitFor(() => {
+ expect(amountInput).toHaveProp('value', '5000');
+ expect(getByText(/\$150/)).toBeOnTheScreen();
+ });
- const input = getByTestId('musd-amount-input');
- fireEvent.changeText(input, 'abc123def');
+ fireEvent(amountInput, 'endEditing');
- expect(input.props.value).toBe('123');
+ await waitFor(() => {
+ expect(amountInput).toHaveProp('value', '$5,000');
+ });
});
- it('allows decimal input', () => {
- const { getByTestId } = render();
+ it('accepts amounts over the slider maximum', async () => {
+ const { getByTestId, getByText } = render();
+ const amountInput = getByTestId('musd-slider-amount-display');
+
+ fireEvent(amountInput, 'focus');
+ fireEvent.changeText(amountInput, '12000');
- const input = getByTestId('musd-amount-input');
- fireEvent.changeText(input, '1000.50');
+ await waitFor(() => {
+ expect(amountInput).toHaveProp('value', '12000');
+ expect(getByText(/\$360/)).toBeOnTheScreen();
+ });
+ });
+
+ it('normalizes decorated decimal input while editing', async () => {
+ const { getByTestId, getByText } = render();
+ const amountInput = getByTestId('musd-slider-amount-display');
+
+ fireEvent(amountInput, 'focus');
+ fireEvent.changeText(amountInput, '$1,234.56.78');
+
+ await waitFor(() => {
+ expect(amountInput).toHaveProp('value', '1234.5678');
+ expect(getByText(/\$37\.037/)).toBeOnTheScreen();
+ });
+ });
+
+ it('treats invalid numeric input as zero', async () => {
+ const { getByTestId, getAllByText } = render();
+ const amountInput = getByTestId('musd-slider-amount-display');
- expect(input.props.value).toBe('1000.50');
+ fireEvent(amountInput, 'focus');
+ fireEvent.changeText(amountInput, '.');
+
+ await waitFor(() => {
+ expect(amountInput).toHaveProp('value', '.');
+ expect(getAllByText(/\$0/).length).toBeGreaterThan(0);
+ });
});
- it('rejects multiple decimal points', () => {
+ it('ignores slider presses before the track is measured', () => {
const { getByTestId } = render();
- const input = getByTestId('musd-amount-input');
- fireEvent.changeText(input, '1000.50');
- fireEvent.changeText(input, '1000.50.25');
+ fireEvent(getByTestId('musd-slider-track'), 'pressIn', {
+ nativeEvent: { locationX: 200 },
+ });
- expect(input.props.value).toBe('1000.50');
+ expect(getByTestId('musd-slider-amount-display')).toHaveProp(
+ 'value',
+ '$1,000',
+ );
});
- it('calculates correct bonus values for $5000 input', async () => {
- const { getByTestId, getByText } = render();
+ it('updates amount through pan gesture handlers', async () => {
+ const { getByTestId } = render();
+ const track = getByTestId('musd-slider-track');
- const input = getByTestId('musd-amount-input');
- fireEvent.changeText(input, '5000');
+ fireEvent(track, 'layout', {
+ nativeEvent: { layout: { width: 300, height: 32, x: 0, y: 0 } },
+ });
+
+ act(() => {
+ mockPanGestureHandlers.onBegin?.({ x: 150 });
+ mockPanGestureHandlers.onUpdate?.({ x: 300 });
+ mockPanGestureHandlers.onFinalize?.({ x: 300 });
+ });
- // Initial amount: $5,000.00
- // Daily bonus: $5000 * 0.03 / 365 = $0.41
- // Annualized bonus: $5000 * 0.03 = $150.00
await waitFor(() => {
- expect(getByText('$5,000')).toBeOnTheScreen();
- expect(getByText('$0.41')).toBeOnTheScreen();
- expect(getByText('$150')).toBeOnTheScreen();
+ expect(getByTestId('musd-slider-amount-display')).toHaveProp(
+ 'value',
+ '$10,000',
+ );
});
});
+
+ it('ignores pan gesture handlers before the track is measured', () => {
+ const { getByTestId } = render();
+
+ act(() => {
+ mockPanGestureHandlers.onBegin?.({ x: 150 });
+ mockPanGestureHandlers.onUpdate?.({ x: 300 });
+ });
+
+ expect(getByTestId('musd-slider-amount-display')).toHaveProp(
+ 'value',
+ '$1,000',
+ );
+ });
});
diff --git a/app/components/UI/Rewards/components/Tabs/MusdCalculatorTab/MusdCalculatorTab.tsx b/app/components/UI/Rewards/components/Tabs/MusdCalculatorTab/MusdCalculatorTab.tsx
index 88d60ad68b36..7d633bf8b824 100644
--- a/app/components/UI/Rewards/components/Tabs/MusdCalculatorTab/MusdCalculatorTab.tsx
+++ b/app/components/UI/Rewards/components/Tabs/MusdCalculatorTab/MusdCalculatorTab.tsx
@@ -1,41 +1,66 @@
-import React, { useCallback, useMemo, useState } from 'react';
-import { ScrollView } from 'react-native';
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import { Keyboard, Pressable, ScrollView, TextInput } from 'react-native';
+import { Gesture, GestureDetector } from 'react-native-gesture-handler';
+import Animated, {
+ runOnJS,
+ useAnimatedStyle,
+ useSharedValue,
+ withTiming,
+} from 'react-native-reanimated';
import {
+ AvatarToken,
+ AvatarTokenSize,
Box,
+ BoxAlignItems,
+ BoxFlexDirection,
+ BoxJustifyContent,
+ Button,
+ ButtonSize,
+ ButtonVariant,
+ FontWeight,
Text,
TextColor,
TextVariant,
- Button,
- ButtonVariant,
- ButtonSize,
} from '@metamask/design-system-react-native';
import { useTailwind } from '@metamask/design-system-twrnc-preset';
-import TextField from '../../../../../../component-library/components/Form/TextField';
-import { strings } from '../../../../../../../locales/i18n';
-import { KeyValueRowStubs } from '../../../../../../component-library/components-temp/KeyValueRow';
-import { handleDeeplink } from '../../../../../../core/DeeplinkManager';
-import useFiatFormatter from '../../../../SimulationDetails/FiatDisplay/useFiatFormatter';
import { BigNumber } from 'bignumber.js';
import { EthScope } from '@metamask/keyring-api';
+import { CaipAssetType } from '@metamask/utils';
+import { strings } from '../../../../../../../locales/i18n';
import Routes from '../../../../../../constants/navigation/Routes';
+import { handleDeeplink } from '../../../../../../core/DeeplinkManager';
+import { MetaMetricsEvents } from '../../../../../../core/Analytics';
import {
SwapBridgeNavigationLocation,
useSwapBridgeNavigation,
} from '../../../../Bridge/hooks/useSwapBridgeNavigation';
import { BridgeToken } from '../../../../Bridge/types';
+import { getTokenIconUrl } from '../../../../Bridge/utils';
+import { getNativeSourceToken } from '../../../../Bridge/utils/tokenUtils';
import {
+ MUSD_CONVERSION_APY,
MUSD_TOKEN,
MUSD_TOKEN_ADDRESS,
MUSD_TOKEN_ASSET_ID_BY_CHAIN,
} from '../../../../Earn/constants/musd';
-import { getTokenIconUrl } from '../../../../Bridge/utils';
-import { CaipAssetType } from '@metamask/utils';
-import { getNativeSourceToken } from '../../../../Bridge/utils/tokenUtils';
+import useFiatFormatter from '../../../../SimulationDetails/FiatDisplay/useFiatFormatter';
import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics';
-import { MetaMetricsEvents } from '../../../../../../core/Analytics';
import { RewardsMetricsButtons } from '../../../utils';
+import {
+ amountToPercent,
+ clampAmount,
+ MUSD_CALCULATOR_APY,
+ percentToAmount,
+ SNAP_POINTS,
+} from '../../../utils/musdCalculatorSlider';
+import type { ImageOrSvgSrc } from '@metamask/design-system-react-native/dist/components/temp-components/ImageOrSvg/ImageOrSvg.types.d.cts';
-const ANNUAL_BONUS_RATE = 0.03;
const BUY_MUSD_URL =
'https://link.metamask.io/buy?address=0xaca92e438df0b2401ff60da7e4337b687a2435da&amount=100&chainid=1&sig_params=address%2Camount%2Cchainid%2Cutm_source&utm_source=rewards&sig=SdHOoh_QvT1bs8B6g-qCyLH5mUEczYzeOfAv9SNRm4CKjR6uBnUp4e1-Vcojb39fWWScBrui2GLftNlJKQlrAQ';
@@ -51,41 +76,164 @@ const MUSD_DEST_TOKEN: BridgeToken = {
),
};
+const TRACK_HEIGHT = 12;
+const THUMB_SIZE_REST = 24;
+const THUMB_SIZE_DRAG = 32;
+const THUMB_DRAG_SCALE = THUMB_SIZE_DRAG / THUMB_SIZE_REST;
+const SLIDER_ROW_HEIGHT = 32;
+/** ms between JS amount updates while dragging — keeps labels in sync without a re-render every frame */
+const SLIDER_AMOUNT_THROTTLE_MS = 48;
+
+const normalizeAmountInput = (value: string) => {
+ const numeric = value.replace(/[^0-9.]/g, '');
+ const [whole = '', ...decimalParts] = numeric.split('.');
+
+ if (decimalParts.length === 0) {
+ return whole;
+ }
+
+ return `${whole}.${decimalParts.join('')}`;
+};
+
const MusdCalculatorTab: React.FC = () => {
const tw = useTailwind();
const { trackEvent, createEventBuilder } = useAnalytics();
- const [musdAmount, setMusdAmount] = useState('1000');
+ const [amount, setAmount] = useState(1000);
+ const amountRef = useRef(1000);
+
+ const thumbScale = useSharedValue(1);
+ const trackWidthShared = useSharedValue(0);
+ /** 0–100 linear track % while dragging (follows finger); at rest matches {@link amountToPercent}(amount). */
+ const thumbLinearPctShared = useSharedValue(amountToPercent(1000));
+ const lastThrottledAmountSyncRef = useRef(0);
const ethSourceToken = useMemo(
() => getNativeSourceToken(EthScope.Mainnet),
[],
);
- const musdCalculations = useMemo(() => {
- const amount = parseFloat(musdAmount) || 0;
- const annualizedBonus = amount * ANNUAL_BONUS_RATE;
- const dailyBonus = annualizedBonus / 365;
- return {
- initialAmount: amount,
- dailyBonus,
- annualizedBonus,
- };
- }, [musdAmount]);
-
const formatFiat = useFiatFormatter({ currency: 'usd' });
const formatCurrency = useCallback(
(value: number) => formatFiat(new BigNumber(value)),
[formatFiat],
);
+ const [amountInputValue, setAmountInputValue] = useState(() =>
+ formatCurrency(1000),
+ );
+ const [isAmountInputFocused, setIsAmountInputFocused] = useState(false);
- const handleMusdAmountChange = useCallback((text: string) => {
- const sanitized = text.replace(/[^0-9.]/g, '');
- const parts = sanitized.split('.');
- if (parts.length > 2) {
- return;
+ useEffect(() => {
+ amountRef.current = amount;
+ if (!isAmountInputFocused) {
+ setAmountInputValue(formatCurrency(amount));
}
- setMusdAmount(sanitized);
- }, []);
+ thumbLinearPctShared.value = amountToPercent(amount);
+ }, [amount, formatCurrency, isAmountInputFocused, thumbLinearPctShared]);
+
+ const scaleMinLabel = useMemo(
+ () => formatCurrency(SNAP_POINTS[0]),
+ [formatCurrency],
+ );
+ const scaleMidLabel = useMemo(
+ () => formatCurrency(SNAP_POINTS[1]),
+ [formatCurrency],
+ );
+ const scaleMaxLabel = useMemo(
+ () => formatCurrency(SNAP_POINTS[2]),
+ [formatCurrency],
+ );
+
+ const yearlyEarnings = useMemo(() => amount * MUSD_CALCULATOR_APY, [amount]);
+ const dailyEarnings = useMemo(
+ () => (amount * MUSD_CALCULATOR_APY) / 365,
+ [amount],
+ );
+
+ const syncAmountFromLinearPct = useCallback(
+ (linearPct: number, force: boolean) => {
+ const now = globalThis.performance.now();
+ if (
+ !force &&
+ now - lastThrottledAmountSyncRef.current < SLIDER_AMOUNT_THROTTLE_MS
+ ) {
+ return;
+ }
+ lastThrottledAmountSyncRef.current = now;
+ setAmount(clampAmount(percentToAmount(linearPct)));
+ },
+ [],
+ );
+
+ const commitSliderAtX = useCallback(
+ (x: number) => {
+ const w = trackWidthShared.value;
+ if (w <= 0) {
+ return;
+ }
+ const clampedX = Math.max(0, Math.min(w, x));
+ const linearPct = (clampedX / w) * 100;
+ thumbLinearPctShared.value = linearPct;
+ const next = clampAmount(percentToAmount(linearPct));
+ setAmount(next);
+ thumbLinearPctShared.value = amountToPercent(next);
+ lastThrottledAmountSyncRef.current = 0;
+ },
+ [thumbLinearPctShared, trackWidthShared],
+ );
+
+ const panGesture = useMemo(
+ () =>
+ Gesture.Pan()
+ .minDistance(2)
+ .onBegin((e) => {
+ thumbScale.value = withTiming(THUMB_DRAG_SCALE, { duration: 150 });
+ const w = trackWidthShared.value;
+ if (w <= 0) {
+ return;
+ }
+ const clampedX = Math.max(0, Math.min(w, e.x));
+ const linearPct = (clampedX / w) * 100;
+ thumbLinearPctShared.value = linearPct;
+ runOnJS(syncAmountFromLinearPct)(linearPct, true);
+ })
+ .onUpdate((e) => {
+ const w = trackWidthShared.value;
+ if (w <= 0) {
+ return;
+ }
+ const clampedX = Math.max(0, Math.min(w, e.x));
+ const linearPct = (clampedX / w) * 100;
+ thumbLinearPctShared.value = linearPct;
+ runOnJS(syncAmountFromLinearPct)(linearPct, false);
+ })
+ .onFinalize((e) => {
+ thumbScale.value = withTiming(1, { duration: 150 });
+ runOnJS(commitSliderAtX)(e.x);
+ }),
+ [
+ thumbScale,
+ thumbLinearPctShared,
+ trackWidthShared,
+ syncAmountFromLinearPct,
+ commitSliderAtX,
+ ],
+ );
+
+ const animatedFillStyle = useAnimatedStyle(() => {
+ const w = trackWidthShared.value;
+ const pct = thumbLinearPctShared.value;
+ return { width: (pct / 100) * w };
+ });
+
+ const animatedThumbStyle = useAnimatedStyle(() => {
+ const w = trackWidthShared.value;
+ const pct = thumbLinearPctShared.value;
+ const filled = (pct / 100) * w;
+ return {
+ transform: [{ scale: thumbScale.value }],
+ left: filled - THUMB_SIZE_REST / 2,
+ };
+ });
const handleBuyMusd = useCallback(() => {
trackEvent(
@@ -112,103 +260,256 @@ const MusdCalculatorTab: React.FC = () => {
goToSwaps();
}, [goToSwaps, trackEvent, createEventBuilder]);
+ const handleAmountInputFocus = useCallback(() => {
+ setIsAmountInputFocused(true);
+ setAmountInputValue(String(amount));
+ }, [amount]);
+
+ const handleAmountInputChange = useCallback((value: string) => {
+ const normalizedValue = normalizeAmountInput(value);
+ setAmountInputValue(normalizedValue);
+
+ const nextAmount = Number(normalizedValue);
+ amountRef.current = Number.isFinite(nextAmount) ? nextAmount : 0;
+ setAmount(amountRef.current);
+ }, []);
+
+ const handleAmountInputEndEditing = useCallback(() => {
+ setIsAmountInputFocused(false);
+ setAmountInputValue(formatCurrency(amountRef.current));
+ }, [formatCurrency]);
+
return (
- {/* Title and Description */}
-
-
- {strings('rewards.musd.title')}
-
-
- {strings('rewards.musd.description')}
-
-
-
- {/* Amount Input */}
-
-
-
- {strings('rewards.musd.amount_label')}
-
-
-
- $}
- placeholder="0"
+
+
+
-
-
-
- {/* Estimated Bonus Rate */}
-
- {strings('rewards.musd.estimated_bonus')}
-
-
- {/* Results Card */}
-
-
-
- {strings('rewards.musd.initial_amount')}
-
-
- {formatCurrency(musdCalculations.initialAmount)}
-
-
-
-
- {strings('rewards.musd.daily_bonus')}
-
-
- {formatCurrency(musdCalculations.dailyBonus)}
-
-
+
+
+ {strings('rewards.musd.hero_hold')}
+
+
+ {strings('rewards.musd.hero_earn')}
+
+
+
+
+
+
+ {strings('rewards.musd.yearly_positive_prefix')}
+ {formatCurrency(yearlyEarnings)}
+
+
+ {strings('rewards.musd.earnings_per_year_suffix')}
+
+
+
+
+ {formatCurrency(dailyEarnings)}
+ {strings('rewards.musd.earnings_per_day_suffix')}
+
+
+
+
+
+
+
+ {strings('rewards.musd.slider_amount_label')}
+
+
+
-
-
- {strings('rewards.musd.annualized_bonus')}
-
-
- {formatCurrency(musdCalculations.annualizedBonus)}
-
+
+ {
+ trackWidthShared.value = event.nativeEvent.layout.width;
+ }}
+ onPressIn={(event) => {
+ commitSliderAtX(event.nativeEvent.locationX);
+ }}
+ style={tw.style('h-8 w-full justify-center')}
+ >
+
+
+
+
+
+
+
+
+
+ {scaleMinLabel}
+
+
+
+
+ {scaleMidLabel}
+
+
+
+
+ {scaleMaxLabel}
+
+
+
+
-
- {/* Action Buttons */}
-
-
-
+
+
+
-
- {/* Disclaimer */}
-
- {strings('rewards.musd.disclaimer_brief')}
-
);
};
diff --git a/app/components/UI/Rewards/utils/musdCalculatorSlider.test.ts b/app/components/UI/Rewards/utils/musdCalculatorSlider.test.ts
new file mode 100644
index 000000000000..b8de8f1908a4
--- /dev/null
+++ b/app/components/UI/Rewards/utils/musdCalculatorSlider.test.ts
@@ -0,0 +1,24 @@
+import { MUSD_CONVERSION_APY } from '../../Earn/constants/musd';
+import {
+ amountToPercent,
+ clampAmount,
+ MUSD_CALCULATOR_APY,
+ percentToAmount,
+} from './musdCalculatorSlider';
+
+describe('musdCalculatorSlider', () => {
+ it('maps amount to percent and back for mid-range values', () => {
+ const pct = amountToPercent(5000);
+ expect(pct).toBeCloseTo(72.222222, 5);
+ expect(clampAmount(percentToAmount(pct))).toBe(5000);
+ });
+
+ it('snaps to anchor amounts near snap points', () => {
+ expect(clampAmount(percentToAmount(amountToPercent(1000)))).toBe(1000);
+ expect(clampAmount(percentToAmount(amountToPercent(10000)))).toBe(10000);
+ });
+
+ it('derives calculator APY from Earn MUSD_CONVERSION_APY', () => {
+ expect(MUSD_CALCULATOR_APY).toBe(MUSD_CONVERSION_APY / 100);
+ });
+});
diff --git a/app/components/UI/Rewards/utils/musdCalculatorSlider.ts b/app/components/UI/Rewards/utils/musdCalculatorSlider.ts
new file mode 100644
index 000000000000..3af52f0693c3
--- /dev/null
+++ b/app/components/UI/Rewards/utils/musdCalculatorSlider.ts
@@ -0,0 +1,68 @@
+/**
+ * Pure helpers for the Rewards mUSD calculator slider (amount ↔ track percent,
+ * snapping, and clamping). Ported from the standalone mUSD calculator prototype.
+ */
+
+import { MUSD_CONVERSION_APY } from '../../Earn/constants/musd';
+
+export const MIN_AMOUNT = 100;
+export const MAX_AMOUNT = 10000;
+/**
+ * Annual yield as a decimal fraction for earnings math (e.g. 0.03 for 3%).
+ * Derived from {@link MUSD_CONVERSION_APY} so Earn conversion and Rewards stay aligned.
+ */
+export const MUSD_CALCULATOR_APY = MUSD_CONVERSION_APY / 100;
+
+export const SNAP_POINTS = [100, 1000, 10000] as const;
+export const SNAP_THRESHOLD_PCT = 2;
+
+/**
+ * Maps a dollar amount to a 0–100 slider position. The mapping is piecewise-linear
+ * with extra resolution between $100 and $1,000 (first half of the track).
+ */
+export function amountToPercent(amount: number): number {
+ if (amount <= MIN_AMOUNT) {
+ return 0;
+ }
+ if (amount >= MAX_AMOUNT) {
+ return 100;
+ }
+ if (amount <= 1000) {
+ return ((amount - 100) / (1000 - 100)) * 50;
+ }
+ return 50 + ((amount - 1000) / (MAX_AMOUNT - 1000)) * 50;
+}
+
+/**
+ * Converts a 0–100 track position to a dollar amount, including rounding and snap
+ * points at {@link SNAP_POINTS}.
+ */
+export function percentToAmount(pct: number): number {
+ let raw: number;
+ if (pct <= 50) {
+ raw = 100 + (pct / 50) * (1000 - 100);
+ } else {
+ raw = 1000 + ((pct - 50) / 50) * (MAX_AMOUNT - 1000);
+ }
+
+ if (raw <= 500) {
+ raw = Math.round(raw / 10) * 10;
+ } else if (raw <= 2000) {
+ raw = Math.round(raw / 50) * 50;
+ } else {
+ raw = Math.round(raw / 100) * 100;
+ }
+
+ for (const snap of SNAP_POINTS) {
+ const snapPct = amountToPercent(snap);
+ if (Math.abs(pct - snapPct) < SNAP_THRESHOLD_PCT) {
+ return snap;
+ }
+ }
+
+ return raw;
+}
+
+export function clampAmount(value: number): number {
+ return Math.max(MIN_AMOUNT, Math.min(MAX_AMOUNT, value));
+}
diff --git a/locales/languages/de.json b/locales/languages/de.json
index de51c7d3d4eb..1022cad333ea 100644
--- a/locales/languages/de.json
+++ b/locales/languages/de.json
@@ -8391,15 +8391,6 @@
"season_1": "Saison 1",
"musd": {
"page_title": "mUSD",
- "title": "mUSD-Bonus-Rechner",
- "description": "Finden Sie heraus, wie viel Sie durch die Konvertierung Ihrer Stablecoins in mUSD verdienen können.",
- "amount_label": "Konvertierter Betrag",
- "estimated_bonus": "Geschätzter jährlicher Bonus: bis zu 3 %",
- "initial_amount": "Anfangsbetrag",
- "daily_bonus": "Täglich einforderbarer Bonus",
- "annualized_bonus": "Jährlicher Bonus",
- "disclaimer": "Dies ist nur eine Schätzung. Der Bonus kann sich noch ändern.",
- "disclaimer_brief": "Der Bonus ist ein Schätzwert und kann sich ändern.",
"buy_button": "mUSD kaufen",
"swap_button": "Swap zu mUSD"
},
diff --git a/locales/languages/el.json b/locales/languages/el.json
index 18b539b9e580..ec111a27eacf 100644
--- a/locales/languages/el.json
+++ b/locales/languages/el.json
@@ -8391,15 +8391,6 @@
"season_1": "Περίοδος 1",
"musd": {
"page_title": "mUSD",
- "title": "Εκτίμηση του μπόνους σε mUSD",
- "description": "Δείτε πόσα μπορείτε να κερδίσετε μετατρέποντας τα stablecoins σας σε mUSD.",
- "amount_label": "Το ποσό μετατράπηκε",
- "estimated_bonus": "Εκτιμώμενο ετήσιο μπόνους: έως 3%",
- "initial_amount": "Αρχικό ποσό",
- "daily_bonus": "Ημερήσιο διαθέσιμο μπόνους",
- "annualized_bonus": "Ετήσιο μπόνους",
- "disclaimer": "Πρόκειται μόνο για εκτίμηση. Το μπόνους ενδέχεται να αλλάξει.",
- "disclaimer_brief": "Το μπόνους είναι κατ' εκτίμηση και μπορεί να αλλάξει.",
"buy_button": "Αγοράστε mUSD",
"swap_button": "Ανταλλαγή σε mUSD"
},
diff --git a/locales/languages/en.json b/locales/languages/en.json
index 0708980956ce..d69d3f098ee4 100644
--- a/locales/languages/en.json
+++ b/locales/languages/en.json
@@ -8432,16 +8432,15 @@
"active_boosts_title": "Active boosts",
"season_1": "Season 1",
"musd": {
- "page_title": "mUSD",
- "title": "mUSD bonus calculator",
- "description": "See how much you could earn by converting your stablecoins to mUSD.",
- "amount_label": "Amount converted",
- "estimated_bonus": "Estimated annualized bonus: up to 3%",
- "initial_amount": "Initial amount",
- "daily_bonus": "Daily claimable bonus",
- "annualized_bonus": "Annualized bonus",
- "disclaimer": "This is only an estimate. The bonus is subject to change.",
- "disclaimer_brief": "The bonus is an estimate and may change.",
+ "page_title": "mUSD calculator",
+ "hero_hold": "Hold mUSD.",
+ "hero_earn": "Earn",
+ "yearly_positive_prefix": "+",
+ "earnings_per_year_suffix": "/yr",
+ "earnings_per_month_suffix": "/mo",
+ "earnings_per_day_suffix": "/day claimable daily",
+ "slider_amount_label": "Amount",
+ "disclaimer_calculator": "Estimate based on 3% bonus. Rate may change. Claimable anytime.",
"buy_button": "Buy mUSD",
"swap_button": "Swap to mUSD"
},
diff --git a/locales/languages/es.json b/locales/languages/es.json
index c35c1bfac80a..e4ce30a35858 100644
--- a/locales/languages/es.json
+++ b/locales/languages/es.json
@@ -8391,15 +8391,6 @@
"season_1": "Temporada 1",
"musd": {
"page_title": "mUSD",
- "title": "Calculadora del bono en mUSD",
- "description": "Descubre cuánto podrías ganar al convertir tus monedas estables a mUSD.",
- "amount_label": "Monto convertido",
- "estimated_bonus": "Bono anualizado estimado: hasta 3 %",
- "initial_amount": "Monto inicial",
- "daily_bonus": "Bono diario reclamable",
- "annualized_bonus": "Bono anualizado",
- "disclaimer": "Esto es solo una estimación. El bono está sujeto a cambios.",
- "disclaimer_brief": "El bono es una estimación y puede variar.",
"buy_button": "Comprar mUSD",
"swap_button": "Canjear por mUSD"
},
diff --git a/locales/languages/fr.json b/locales/languages/fr.json
index fee85edbbb64..7a4624084278 100644
--- a/locales/languages/fr.json
+++ b/locales/languages/fr.json
@@ -8391,15 +8391,6 @@
"season_1": "Saison 1",
"musd": {
"page_title": "mUSD",
- "title": "Calculateur du bonus en mUSD",
- "description": "Découvrez combien vous pourriez gagner en convertissant vos stablecoins en mUSD.",
- "amount_label": "Montant converti",
- "estimated_bonus": "Bonus annualisé estimé : jusqu’à 3 %",
- "initial_amount": "Montant initial",
- "daily_bonus": "Bonus quotidien pouvant être réclamé",
- "annualized_bonus": "Bonus annualisé",
- "disclaimer": "Il ne s’agit que d’une estimation. Le montant du bonus peut varier.",
- "disclaimer_brief": "Le bonus est une estimation et peut varier.",
"buy_button": "Acheter des mUSD",
"swap_button": "Échanger en mUSD"
},
diff --git a/locales/languages/hi.json b/locales/languages/hi.json
index e1b0f6b416de..17215bbc20a8 100644
--- a/locales/languages/hi.json
+++ b/locales/languages/hi.json
@@ -8391,15 +8391,6 @@
"season_1": "सीज़न 1",
"musd": {
"page_title": "mUSD",
- "title": "mUSD बोनस कैलकुलेटर",
- "description": "देखें कि आप अपने स्टेबलकॉइन को mUSD में बदलकर कितना कमा सकते हैं।",
- "amount_label": "कन्वर्ट किया गया अमाउंट",
- "estimated_bonus": "अनुमानित सालाना बोनस: 3% तक",
- "initial_amount": "शुरुआती अमाउंट",
- "daily_bonus": "रोज़ाना क्लेम किया जाने वाला बोनस",
- "annualized_bonus": "सालाना बोनस",
- "disclaimer": "यह सिर्फ़ एक अनुमान है। बोनस बदल सकता है।",
- "disclaimer_brief": "बोनस एक अनुमान है और बदल सकता है।",
"buy_button": "mUSD खरीदें",
"swap_button": "mUSD पर स्वैप करें"
},
diff --git a/locales/languages/id.json b/locales/languages/id.json
index 504c9d778b29..2baae2f39536 100644
--- a/locales/languages/id.json
+++ b/locales/languages/id.json
@@ -8391,15 +8391,6 @@
"season_1": "Musim 1",
"musd": {
"page_title": "mUSD",
- "title": "kalkulator bonus mUSD",
- "description": "Lihat berapa banyak yang bisa diperoleh dengan mengonversi stablecoin Anda menjadi mUSD.",
- "amount_label": "Jumlah yang dikonversi",
- "estimated_bonus": "Estimasi bonus tahunan: hingga 3%",
- "initial_amount": "Jumlah awal",
- "daily_bonus": "Bonus yang dapat diklaim setiap hari",
- "annualized_bonus": "Bonus tahunan",
- "disclaimer": "Ini hanya estimasi. Bonus dapat berubah sewaktu-waktu.",
- "disclaimer_brief": "Bonus tersebut merupakan estimasi dan dapat berubah.",
"buy_button": "Beli mUSD",
"swap_button": "Swap ke mUSD"
},
diff --git a/locales/languages/ja.json b/locales/languages/ja.json
index 216cbf2b7d30..8d8fd14fb7ed 100644
--- a/locales/languages/ja.json
+++ b/locales/languages/ja.json
@@ -8391,15 +8391,6 @@
"season_1": "シーズン1",
"musd": {
"page_title": "mUSD",
- "title": "mUSDボーナス計算ツール",
- "description": "ステーブルコインをmUSDに交換することで、どれだけの利益が得られるか確認しましょう。",
- "amount_label": "変換金額",
- "estimated_bonus": "推定年率ボーナス:最大3%",
- "initial_amount": "初期金額",
- "daily_bonus": "1日あたりに獲得可能なボーナス",
- "annualized_bonus": "年率ボーナス",
- "disclaimer": "これはあくまで目安です。ボーナスは変更される場合があります。",
- "disclaimer_brief": "ボーナスは見込み額であり、変動する場合があります。",
"buy_button": "mUSDを購入",
"swap_button": "mUSDにスワップ"
},
diff --git a/locales/languages/ko.json b/locales/languages/ko.json
index 8ffd7cc01023..2523b407944d 100644
--- a/locales/languages/ko.json
+++ b/locales/languages/ko.json
@@ -8391,15 +8391,6 @@
"season_1": "시즌 1",
"musd": {
"page_title": "mUSD",
- "title": "mUSD 보너스 계산기",
- "description": "스테이블코인을 mUSD로 전환하면 얼마나 적립할 수 있는지 확인해 보세요.",
- "amount_label": "전환 금액",
- "estimated_bonus": "예상 연환산 보너스: 최대 3%",
- "initial_amount": "초기 금액",
- "daily_bonus": "매일 청구 가능 보너스",
- "annualized_bonus": "연환산 보너스",
- "disclaimer": "이는 추정치일 뿐입니다. 보너스는 변경될 수 있습니다.",
- "disclaimer_brief": "보너스는 예상치이며 변경될 수 있습니다.",
"buy_button": "mUSD 구매",
"swap_button": "mUSD로 스왑"
},
diff --git a/locales/languages/pt.json b/locales/languages/pt.json
index 15075d9f275b..d8c4eb024d1e 100644
--- a/locales/languages/pt.json
+++ b/locales/languages/pt.json
@@ -8391,15 +8391,6 @@
"season_1": "Temporada 1",
"musd": {
"page_title": "mUSD",
- "title": "Calculadora de bônus mUSD",
- "description": "Veja quanto você pode ganhar convertendo suas stablecoins para mUSD.",
- "amount_label": "Valor convertido",
- "estimated_bonus": "Bônus anual estimado: até 3%",
- "initial_amount": "Valor inicial",
- "daily_bonus": "Bônus diário resgatável",
- "annualized_bonus": "Bônus anualizado",
- "disclaimer": "Este valor é apenas uma estimativa. O bônus está sujeito a alterações.",
- "disclaimer_brief": "O bônus é uma estimativa e pode sofrer alterações.",
"buy_button": "Comprar mUSD",
"swap_button": "Converter para mUSD"
},
diff --git a/locales/languages/ru.json b/locales/languages/ru.json
index dc7d93ec1341..3cb60328a342 100644
--- a/locales/languages/ru.json
+++ b/locales/languages/ru.json
@@ -8391,15 +8391,6 @@
"season_1": "Сезон 1",
"musd": {
"page_title": "mUSD",
- "title": "Калькулятор бонусов mUSD",
- "description": "Посмотрите, сколько вы могли бы заработать, конвертировав ваши стейблкоины в mUSD.",
- "amount_label": "Сконвертированная сумма",
- "estimated_bonus": "Расчетный годовой бонус: до 3%",
- "initial_amount": "Начальная сумма",
- "daily_bonus": "Ежедневный бонус, доступный для получения",
- "annualized_bonus": "Годовой бонус",
- "disclaimer": "Это лишь приблизительная оценка. Размер бонуса может измениться.",
- "disclaimer_brief": "Бонус является приблизительным и может измениться.",
"buy_button": "Купить mUSD",
"swap_button": "Обменять на mUSD"
},
diff --git a/locales/languages/tl.json b/locales/languages/tl.json
index 557ba86be909..5ad1f6ded7ff 100644
--- a/locales/languages/tl.json
+++ b/locales/languages/tl.json
@@ -8391,15 +8391,6 @@
"season_1": "Season 1",
"musd": {
"page_title": "mUSD",
- "title": "bonus calculator ng mUSD",
- "description": "Tingnan kung magkano ang maaari mong kitain sa pamamagitan ng pag-convert sa mga stablecoin mo sa mUSD.",
- "amount_label": "Halagang na-convert",
- "estimated_bonus": "Tinatayang taunang bonus: hanggang 3%",
- "initial_amount": "Paunang halaga",
- "daily_bonus": "Maki-claim na bonus araw-araw",
- "annualized_bonus": "Taunang bonus",
- "disclaimer": "Pagtataya lamang ito. Maaaring magbago ang bonus.",
- "disclaimer_brief": "Ang bonus ay tinantya lamang at maaaring magbago.",
"buy_button": "Bumili ng mUSD",
"swap_button": "I-swap sa mUSD"
},
diff --git a/locales/languages/tr.json b/locales/languages/tr.json
index ed0315f8a6e3..2f997635b806 100644
--- a/locales/languages/tr.json
+++ b/locales/languages/tr.json
@@ -8391,15 +8391,6 @@
"season_1": "Sezon 1",
"musd": {
"page_title": "mUSD",
- "title": "mUSD bonus hesaplayıcı",
- "description": "Stabil kripto paralarınızı mUSD'ye dönüştürerek ne kadar kazanabileceğinize bakın.",
- "amount_label": "Dönüştürülen miktar",
- "estimated_bonus": "Tahmini yıllıklandırılmış bonus: %3'e kadar",
- "initial_amount": "Başlangıç miktarı",
- "daily_bonus": "Günlük alınabilir bonus",
- "annualized_bonus": "Yıllıklandırılmış bonus",
- "disclaimer": "Bu yalnızca bir tahmindir. Bonus değişikliğe tabidir.",
- "disclaimer_brief": "Bonus tahminidir ve değişebilir.",
"buy_button": "mUSD al",
"swap_button": "mUSD'ye takas et"
},
diff --git a/locales/languages/vi.json b/locales/languages/vi.json
index 8b0a99647890..f104a27cb816 100644
--- a/locales/languages/vi.json
+++ b/locales/languages/vi.json
@@ -8391,15 +8391,6 @@
"season_1": "Mùa 1",
"musd": {
"page_title": "mUSD",
- "title": "Máy tính thưởng mUSD",
- "description": "Xem bạn có thể nhận được bao nhiêu khi chuyển đổi đồng ổn định của mình sang mUSD.",
- "amount_label": "Số lượng đã chuyển đổi",
- "estimated_bonus": "Thưởng ước tính theo năm: tối đa 3%",
- "initial_amount": "Số lượng ban đầu",
- "daily_bonus": "Thưởng có thể nhận hằng ngày",
- "annualized_bonus": "Thưởng theo năm",
- "disclaimer": "Đây chỉ là ước tính. Phần thưởng có thể thay đổi.",
- "disclaimer_brief": "Mức thưởng chỉ là ước tính và có thể thay đổi.",
"buy_button": "Mua mUSD",
"swap_button": "Hoán đổi sang mUSD"
},
diff --git a/locales/languages/zh.json b/locales/languages/zh.json
index 5d545f91847a..fc6769f3816a 100644
--- a/locales/languages/zh.json
+++ b/locales/languages/zh.json
@@ -8391,15 +8391,6 @@
"season_1": "第 1 季",
"musd": {
"page_title": "mUSD",
- "title": "mUSD 奖励计算器",
- "description": "看看您将稳定币兑换为 mUSD 能赚取多少收益。",
- "amount_label": "已兑换金额",
- "estimated_bonus": "预估年化奖励:最高可达 3%",
- "initial_amount": "初始金额",
- "daily_bonus": "每日可领取奖励",
- "annualized_bonus": "年化奖励",
- "disclaimer": "仅为预估。奖励可能会有变动。",
- "disclaimer_brief": "此奖励为预估金额,可能发生变动。",
"buy_button": "购买 mUSD",
"swap_button": "兑换为 mUSD"
},