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" },