diff --git a/app/components/UI/AssetOverview/ChartNavigationButton/ChartNavigationButton.tsx b/app/components/UI/AssetOverview/ChartNavigationButton/ChartNavigationButton.tsx index e016c73ab315..cc33cec08c89 100644 --- a/app/components/UI/AssetOverview/ChartNavigationButton/ChartNavigationButton.tsx +++ b/app/components/UI/AssetOverview/ChartNavigationButton/ChartNavigationButton.tsx @@ -12,6 +12,7 @@ interface ChartNavigationButtonProps { label: string; selected: boolean; } + const ChartNavigationButton = ({ onPress, label, diff --git a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/VaultHistoricRewardsChart/ChartTimespanButtonGroup/ChartTimespanButtonGroup.styles.ts b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/ChartTimespanButtonGroup/ChartTimespanButtonGroup.styles.ts similarity index 97% rename from app/components/UI/Stake/components/PoolStakingLearnMoreModal/VaultHistoricRewardsChart/ChartTimespanButtonGroup/ChartTimespanButtonGroup.styles.ts rename to app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/ChartTimespanButtonGroup/ChartTimespanButtonGroup.styles.ts index 3771e7285225..0c807ee16438 100644 --- a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/VaultHistoricRewardsChart/ChartTimespanButtonGroup/ChartTimespanButtonGroup.styles.ts +++ b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/ChartTimespanButtonGroup/ChartTimespanButtonGroup.styles.ts @@ -17,6 +17,7 @@ const styleSheet = (params: { marginTop: 24, paddingBottom: 16, paddingHorizontal: 16, + flexWrap: 'wrap', }, chartTimespanButton: { flexDirection: 'column', diff --git a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/VaultHistoricRewardsChart/ChartTimespanButtonGroup/ChartTimespanButtonGroup.test.tsx b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/ChartTimespanButtonGroup/ChartTimespanButtonGroup.test.tsx similarity index 100% rename from app/components/UI/Stake/components/PoolStakingLearnMoreModal/VaultHistoricRewardsChart/ChartTimespanButtonGroup/ChartTimespanButtonGroup.test.tsx rename to app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/ChartTimespanButtonGroup/ChartTimespanButtonGroup.test.tsx diff --git a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/VaultHistoricRewardsChart/ChartTimespanButtonGroup/ChartTimespanButtonGroup.types.ts b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/ChartTimespanButtonGroup/ChartTimespanButtonGroup.types.ts similarity index 100% rename from app/components/UI/Stake/components/PoolStakingLearnMoreModal/VaultHistoricRewardsChart/ChartTimespanButtonGroup/ChartTimespanButtonGroup.types.ts rename to app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/ChartTimespanButtonGroup/ChartTimespanButtonGroup.types.ts diff --git a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/VaultHistoricRewardsChart/ChartTimespanButtonGroup/index.tsx b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/ChartTimespanButtonGroup/index.tsx similarity index 100% rename from app/components/UI/Stake/components/PoolStakingLearnMoreModal/VaultHistoricRewardsChart/ChartTimespanButtonGroup/index.tsx rename to app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/ChartTimespanButtonGroup/index.tsx diff --git a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/VaultHistoricRewardsChart/DataGradient/index.tsx b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/DataGradient/index.tsx similarity index 77% rename from app/components/UI/Stake/components/PoolStakingLearnMoreModal/VaultHistoricRewardsChart/DataGradient/index.tsx rename to app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/DataGradient/index.tsx index 0fba6b740552..2c5505d6bfe0 100644 --- a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/VaultHistoricRewardsChart/DataGradient/index.tsx +++ b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/DataGradient/index.tsx @@ -10,24 +10,12 @@ interface DataGradientProps { const getGradientOpacityByDataSetSize = (dataSetSize: number) => { let opacityTop, opacityBottom; - if (dataSetSize <= 10) { + if (dataSetSize <= 30) { opacityTop = 0.2; - opacityBottom = 0; - } - - if (dataSetSize >= 30) { - opacityTop = 0.35; - opacityBottom = 0; - } - - if (dataSetSize >= 60) { + opacityBottom = 0.2; + } else { opacityTop = 0.4; - opacityBottom = 0; - } - - if (dataSetSize >= 100) { - opacityTop = 0.7; - opacityBottom = 0.25; + opacityBottom = 0.2; } return { opacityTop, opacityBottom }; @@ -48,7 +36,7 @@ const DataGradient = ({ dataPoints, color }: DataGradientProps) => { return ( - + from react-native-svg-charts // src: https://github.com/JesperLekland/react-native-svg-charts x?: (index: number) => number; @@ -14,21 +13,14 @@ interface TooltipProps { ticks?: number[]; } -const Tooltip = ({ - dailyAprs, - currentX, - x, - y, - lineColor, - circleColor, -}: TooltipProps) => { +const GraphCursor = ({ data, currentX, x, y, color }: GraphCursorProps) => { const { colors } = useTheme(); const defaultColor = colors.success.default; - if ((currentX && currentX < 0) || !dailyAprs) return null; + if ((currentX && currentX < 0) || !data) return null; - const selectedDailyApr = dailyAprs[currentX]; + const selectedDailyApr = data[currentX]; // Prevents crash when attempting to parse small floating point numbers (e.g. 0.0123) if (!selectedDailyApr) return null; @@ -39,19 +31,19 @@ const Tooltip = ({ ); }; -export default Tooltip; +export default GraphCursor; diff --git a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/GraphTooltip/GraphTooltip.styles.ts b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/GraphTooltip/GraphTooltip.styles.ts new file mode 100644 index 000000000000..e228c759d3c5 --- /dev/null +++ b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/GraphTooltip/GraphTooltip.styles.ts @@ -0,0 +1,12 @@ +import { StyleSheet } from 'react-native'; + +const styleSheet = () => + StyleSheet.create({ + container: { + paddingVertical: 16, + gap: 4, + alignItems: 'center', + }, + }); + +export default styleSheet; diff --git a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/GraphTooltip/index.tsx b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/GraphTooltip/index.tsx new file mode 100644 index 000000000000..4b5dacaa3357 --- /dev/null +++ b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/GraphTooltip/index.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { View } from 'react-native'; +import Text, { + TextVariant, + TextColor, +} from '../../../../../../../component-library/components/Texts/Text'; +import { useStyles } from '../../../../../../hooks/useStyles'; +import styleSheet from './GraphTooltip.styles'; + +interface GraphTooltipProps { + title: string; + subtitle: string; + color?: string; +} + +const GraphTooltip = ({ title, subtitle, color }: GraphTooltipProps) => { + const { styles } = useStyles(styleSheet, {}); + + return ( + + + {title} + + + {subtitle} + + + ); +}; + +export default GraphTooltip; diff --git a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/InteractiveTimespanChart.constants.ts b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/InteractiveTimespanChart.constants.ts new file mode 100644 index 000000000000..e6e933cc6800 --- /dev/null +++ b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/InteractiveTimespanChart.constants.ts @@ -0,0 +1,14 @@ +import { colors } from '@metamask/design-tokens'; +import { CHART_BUTTONS } from './InteractiveTimespanChart'; +import { GraphOptions } from './InteractiveTimespanChart.types'; + +const DEFAULT_INSET = 0; + +export const DEFAULT_GRAPH_OPTIONS: GraphOptions = { + insetTop: DEFAULT_INSET, + insetRight: DEFAULT_INSET, + insetBottom: DEFAULT_INSET, + insetLeft: DEFAULT_INSET, + timespanButtons: CHART_BUTTONS, + color: colors.light.success.default, +}; diff --git a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/VaultHistoricRewardsChart/VaultHistoricRewardsChart.styles.ts b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/InteractiveTimespanChart.styles.ts similarity index 53% rename from app/components/UI/Stake/components/PoolStakingLearnMoreModal/VaultHistoricRewardsChart/VaultHistoricRewardsChart.styles.ts rename to app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/InteractiveTimespanChart.styles.ts index b8f9f1fed4d2..011750b314dc 100644 --- a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/VaultHistoricRewardsChart/VaultHistoricRewardsChart.styles.ts +++ b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/InteractiveTimespanChart.styles.ts @@ -3,18 +3,10 @@ import { StyleSheet } from 'react-native'; const styleSheet = () => StyleSheet.create({ chartContainer: { - flexDirection: 'column', - justifyContent: 'flex-start', paddingVertical: 16, }, chart: { height: 112, - // paddingHorizontal: 8, - }, - earningRate: { - paddingVertical: 16, - gap: 4, - alignItems: 'center', }, }); diff --git a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/InteractiveTimespanChart.ts b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/InteractiveTimespanChart.ts new file mode 100644 index 000000000000..defd7a99b319 --- /dev/null +++ b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/InteractiveTimespanChart.ts @@ -0,0 +1,22 @@ +import { strings } from '../../../../../../../locales/i18n'; +import { ChartButton } from './ChartTimespanButtonGroup/ChartTimespanButtonGroup.types'; + +// Small dataset ~10 points or less +export const SMALL_DATASET_THRESHOLD = 10; +export const SMALL_DATASET_PADDING = 16; +// Large dataset ~90 points and more +export const SMALL_DATASET_SNAP_RATIO = 0.5; + +export const CHART_BUTTONS: ChartButton[] = [ + { label: strings('stake.interactive_chart.timespan_buttons.7D'), value: 7 }, + { label: strings('stake.interactive_chart.timespan_buttons.1M'), value: 30 }, + { label: strings('stake.interactive_chart.timespan_buttons.3M'), value: 90 }, + { label: strings('stake.interactive_chart.timespan_buttons.6M'), value: 180 }, +]; + +export enum CHART_TIMESPAN_VALUES { + ONE_WEEK = 7, + ONE_MONTH = 30, + THREE_MONTHS = 90, + SIX_MONTHS = 180, +} diff --git a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/InteractiveTimespanChart.types.ts b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/InteractiveTimespanChart.types.ts new file mode 100644 index 000000000000..0b14dd97c788 --- /dev/null +++ b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/InteractiveTimespanChart.types.ts @@ -0,0 +1,14 @@ +import { ChartButton } from './ChartTimespanButtonGroup/ChartTimespanButtonGroup.types'; + +export type DataPoint = Record; + +export type Accessor = (point: T) => R; + +export interface GraphOptions { + insetTop: number; + insetBottom: number; + insetLeft: number; + insetRight: number; + timespanButtons: ChartButton[]; + color: string; +} diff --git a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/InteractiveTimespanChart.utils.ts b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/InteractiveTimespanChart.utils.ts new file mode 100644 index 000000000000..a7ff85594ccf --- /dev/null +++ b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/InteractiveTimespanChart.utils.ts @@ -0,0 +1,72 @@ +import { VaultAprs } from '@metamask/stake-sdk'; +import BigNumber from 'bignumber.js'; +import { strings } from '../../../../../../../locales/i18n'; + +export const calculateSegmentCenters = ( + dataPoints: number[] | string[], + segmentWidth: number, +) => + dataPoints.map((_, index) => { + /** + * Ex. If each segment is 30px wide: + * The start position of first segment (index: 0) = 0 * segmentWidth OR 0 * 30px = 0 + * The center position of the first segment (index: 0) = startPosition + segmentWidth / 2 OR 0 + 30 / 2 = 15 + */ + const startOfSegment = index * segmentWidth; + const centerOfSegment = startOfSegment + segmentWidth / 2; + return centerOfSegment; + }); + +export const formatChartDate = (timestamp: string) => + new Date(timestamp).toUTCString().split(' ').slice(0, 4).join(' '); + +// Example: Sun, 01 Dec 2024 +export const formatDailyAprReward = (reward: { + daily_apy: string; + timestamp: string; +}) => ({ + apr: `${new BigNumber(reward.daily_apy).toFixed(2, BigNumber.ROUND_DOWN)}%`, + timestamp: new Date(reward.timestamp) + .toUTCString() + .split(' ') + .slice(0, 4) + .join(' '), +}); + +export const getGraphContentInset = (dataPoints: number[]) => { + let inset = 0; + + if (dataPoints.length <= 10) inset = 20; + + if (dataPoints.length >= 30) inset = 15; + + if (dataPoints.length >= 90) inset = 10; + + if (dataPoints.length >= 180) inset = 5; + + return inset; +}; + +export const parseVaultTimespanAprsResponse = ( + vaultTimespanAprs: VaultAprs, +) => { + const numDaysMap: Record< + keyof VaultAprs, + { numDays: number; label: string } + > = { + oneDay: { numDays: 1, label: strings('stake.today') }, + oneWeek: { numDays: 7, label: strings('stake.one_week_average') }, + oneMonth: { numDays: 30, label: strings('stake.one_month_average') }, + threeMonths: { numDays: 90, label: strings('stake.three_month_average') }, + sixMonths: { numDays: 180, label: strings('stake.six_month_average') }, + oneYear: { numDays: 365, label: strings('stake.one_year_average') }, + }; + + return Object.entries(vaultTimespanAprs).reduce< + Record + >((map, [key, value]) => { + const numDaysMapEntry = numDaysMap[key as keyof typeof numDaysMap]; + map[numDaysMapEntry.numDays] = { apr: value, ...numDaysMapEntry }; + return map; + }, {}); +}; diff --git a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/VaultHistoricRewardsChart/PlotLine/index.tsx b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/PlotLine/index.tsx similarity index 96% rename from app/components/UI/Stake/components/PoolStakingLearnMoreModal/VaultHistoricRewardsChart/PlotLine/index.tsx rename to app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/PlotLine/index.tsx index 51b533c33524..12fee34f438e 100644 --- a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/VaultHistoricRewardsChart/PlotLine/index.tsx +++ b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/PlotLine/index.tsx @@ -20,7 +20,7 @@ const PlotLine = ({ line, doesChartHaveData, color }: Partial) => { key="line" d={line} stroke={doesChartHaveData ? lineColor : themeColors.text.alternative} - strokeWidth={1.5} + strokeWidth={1.75} fill="none" opacity={doesChartHaveData ? 1 : 0.85} /> diff --git a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/index.tsx b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/index.tsx new file mode 100644 index 000000000000..dd468ce00369 --- /dev/null +++ b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/index.tsx @@ -0,0 +1,266 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { + Dimensions, + GestureResponderEvent, + PanResponder, + View, +} from 'react-native'; +import { AreaChart } from 'react-native-svg-charts'; +import ChartTimespanButtonGroup from './ChartTimespanButtonGroup'; +import DataGradient from './DataGradient'; +import PlotLine from './PlotLine'; +import { + SMALL_DATASET_SNAP_RATIO, + SMALL_DATASET_THRESHOLD, +} from './InteractiveTimespanChart'; +import styleSheet from './InteractiveTimespanChart.styles'; +import { useStyles } from '../../../../../hooks/useStyles'; +import { calculateSegmentCenters } from './InteractiveTimespanChart.utils'; +import GraphCursor from './GraphCursor'; +import { + DataPoint, + Accessor, + GraphOptions, +} from './InteractiveTimespanChart.types'; +import GraphTooltip from './GraphTooltip'; +import { DEFAULT_GRAPH_OPTIONS } from './InteractiveTimespanChart.constants'; + +interface InteractiveTimespanChartProps { + dataPoints: T[]; + /** + * The yAccessor prop informs the graph of which object key to parse Y values from. + * This allows for greater flexibility with the dataPoints passed into the graph. + * + * Example Usage: + * point.daily_apy} + * /> + * + * In the above example, the yAccessor informs the graph that we'd like to extract the daily_apy values and use them as the data points. + */ + yAccessor: Accessor; + titleAccessor?: Accessor; + defaultTitle: string; + subtitleAccessor?: Accessor; + defaultSubtitle: string; + onTimespanPressed?: (numDataPointsToDisplay: number) => void; + graphOptions?: Partial; +} + +/** + * How the Graph Works: + * + * 1. The graph takes an array of number to serve as data points. + * 2. Using the data points, the graph is divided into equal-width segments, one for each data point. This is done by dividing the chart width by the number of data points. + * 3. Using the array of segments, we calculate the center of each segment. This is used for "snapping" with small datasets. + * + * This chart uses a snapping mechanism to feel more intuitive while dragging horizontally with small datasets. + * The chart uses a snap threshold that provides some "give" before transitioning to the next data point. + * This "give" is based on the distance from the center of a segment/data point. + * + * Legend: + * - Segment Widths: The chart is divided into equal-width segments, one for each data point. + * - Segment Centers: Each data point is associated with a center position within its segment to determine where snapping should occur. + * - Snap Threshold: A portion of the segment width (e.g. 25%) that defines how far past a segment's boundary the cursor can go + * before snapping to the next segment. Snapping is only enabled for small datasets since there isn't a need for snapping with large datasets. + */ + +const InteractiveTimespanChart = ({ + dataPoints, + graphOptions, + yAccessor, + defaultTitle, + defaultSubtitle, + titleAccessor, + subtitleAccessor, + onTimespanPressed, +}: InteractiveTimespanChartProps) => { + const { styles } = useStyles(styleSheet, {}); + + const { + insetTop, + insetRight, + insetBottom, + insetLeft, + timespanButtons, + color, + } = { + ...DEFAULT_GRAPH_OPTIONS, + ...graphOptions, + }; + + const [dataPointsToShow, setDataPointsToShow] = useState( + dataPoints.slice(-timespanButtons[0].value), + ); + + /** + * Parse the dataPoints using accessor props to create array of values, titles, and subtitles. + * - values array: The point on the graph. + * - titles array: The available titles for each point on the graph for use in the GraphTooltip. + * - subtitles array: The available subtitles for each point on the graph for use in the GraphTooltip. + * + * When a user selects a point on the graph, the index of this point is used to select the correct value, title, and subtitle. + * + */ + const { parsedDataPointValues, parsedSubtitleValues, parsedTitleValues } = + useMemo(() => { + const values: number[] = []; + const titles: string[] = []; + const subtitles: string[] = []; + + dataPointsToShow.forEach((point) => { + values.push(yAccessor(point)); + if (titleAccessor) { + titles.push(titleAccessor(point)); + } + if (subtitleAccessor) { + subtitles.push(subtitleAccessor(point)); + } + }); + + return { + parsedDataPointValues: values, + parsedTitleValues: titles, + parsedSubtitleValues: subtitles, + }; + }, [dataPointsToShow, subtitleAccessor, titleAccessor, yAccessor]); + + const [selectedPointIndex, setSelectedPointIndex] = useState(-1); + + const doesChartHaveData = useMemo( + () => dataPointsToShow.length > 0, + [dataPointsToShow], + ); + + const chartWidth = Dimensions.get('window').width; + + const chartSegmentWidth = useMemo(() => { + const calculatedSegmentWidth = chartWidth / dataPointsToShow.length; + return parseFloat(calculatedSegmentWidth.toFixed(6)); + }, [chartWidth, dataPointsToShow.length]); + + const segmentCenters = useMemo( + () => calculateSegmentCenters(parsedDataPointValues, chartSegmentWidth), + [chartSegmentWidth, parsedDataPointValues], + ); + + const handleTimespanPressed = (numDataPointsToDisplay: number) => { + setDataPointsToShow(dataPoints.slice(-numDataPointsToDisplay)); + // Remove graph selection when switching between timespans. + setSelectedPointIndex(-1); + onTimespanPressed?.(numDataPointsToDisplay); + }; + + // Determines when the cursor should "snap" (or jump) to the next point. + const snapThreshold = useMemo( + () => + chartSegmentWidth * + // We only enable snapping for small datasets. + (dataPointsToShow.length <= SMALL_DATASET_THRESHOLD + ? SMALL_DATASET_SNAP_RATIO + : 0), + [dataPointsToShow.length, chartSegmentWidth], + ); + + const updateSelectedGraphPosition = useCallback( + (x: number) => { + // Deselect point when finger raised + if (x === -1) { + setSelectedPointIndex(-1); + return; + } + + // Find the closest segment center to the current touch position + let closestIndex = 0; + let minDistance = Infinity; + + segmentCenters.forEach((center, index) => { + const distance = Math.abs(x - center); + if (distance < minDistance) { + closestIndex = index; + minDistance = distance; + } + }); + + /** + * Ensure that small datasets respect snap threshold + * Larger datasets can always update. + */ + if ( + minDistance <= snapThreshold || + dataPointsToShow.length > SMALL_DATASET_THRESHOLD + ) { + setSelectedPointIndex(closestIndex); + } + }, + [dataPointsToShow.length, segmentCenters, snapThreshold], + ); + + /** + * PanResponder captures the dragging on the graph + * src: https://reactnative.dev/docs/panresponder + */ + const panResponder = useMemo( + () => + PanResponder.create({ + onStartShouldSetPanResponder: () => true, + onStartShouldSetPanResponderCapture: () => true, + onMoveShouldSetPanResponder: () => true, + onMoveShouldSetPanResponderCapture: () => true, + onPanResponderTerminationRequest: () => true, + onPanResponderGrant: (evt: GestureResponderEvent) => { + updateSelectedGraphPosition(evt.nativeEvent.locationX); + }, + onPanResponderMove: (evt: GestureResponderEvent) => { + updateSelectedGraphPosition(evt.nativeEvent.locationX); + }, + onPanResponderRelease: () => { + updateSelectedGraphPosition(-1); + }, + }), + [updateSelectedGraphPosition], + ); + + return ( + + + {Boolean(parsedDataPointValues.length) && ( + + )} + + + + {doesChartHaveData && ( + + )} + + + + + ); +}; + +export default InteractiveTimespanChart; diff --git a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/VaultHistoricRewardsChart/VaultHistoricRewardsChart.constants.ts b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/VaultHistoricRewardsChart/VaultHistoricRewardsChart.constants.ts deleted file mode 100644 index d93eea23fddf..000000000000 --- a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/VaultHistoricRewardsChart/VaultHistoricRewardsChart.constants.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ChartButton } from './ChartTimespanButtonGroup/ChartTimespanButtonGroup.types'; - -// Small dataset ~10 points or less -export const SMALL_DATASET_THRESHOLD = 10; -export const SMALL_DATASET_PADDING = 16; -// Large dataset ~90 points and more -export const SMALL_DATASET_SNAP_RATIO = 0.5; - -export const CHART_BUTTONS: ChartButton[] = [ - { label: '7D', value: 7 }, - { label: '1M', value: 30 }, - { label: '3M', value: 90 }, - { label: '6M', value: 180 }, - // We don't have enough data points for 1 year yet - // { label: '12M', value: 360 }, -]; diff --git a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/VaultHistoricRewardsChart/VaultHistoricRewardsChart.test.tsx b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/VaultHistoricRewardsChart/VaultHistoricRewardsChart.test.tsx deleted file mode 100644 index 7082b5f0ddae..000000000000 --- a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/VaultHistoricRewardsChart/VaultHistoricRewardsChart.test.tsx +++ /dev/null @@ -1 +0,0 @@ -// TODO: ADD TESTS diff --git a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/VaultHistoricRewardsChart/VaultHistoricRewardsChart.utils.ts b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/VaultHistoricRewardsChart/VaultHistoricRewardsChart.utils.ts deleted file mode 100644 index 08e7eb8258e2..000000000000 --- a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/VaultHistoricRewardsChart/VaultHistoricRewardsChart.utils.ts +++ /dev/null @@ -1,43 +0,0 @@ -import BigNumber from 'bignumber.js'; - -export const calculateSegmentCenters = ( - dataPoints: number[], - segmentWidth: number, -) => - dataPoints.map((_, index) => { - /** - * Ex. If each segment is 30px wide: - * The start position of first segment (index: 0) = 0 * segmentWidth OR 0 * 30px = 0 - * The center position of the first segment (index: 0) = startPosition + segmentWidth / 2 OR 0 + 30 / 2 = 15 - */ - const startOfSegment = index * segmentWidth; - const centerOfSegment = startOfSegment + segmentWidth / 2; - return centerOfSegment; - }); - -// Example: Sun, 01 Dec 2024 -export const formatDailyAprReward = (reward: { - daily_apy: string; - timestamp: string; -}) => ({ - apr: `${new BigNumber(reward.daily_apy).toFixed(2, BigNumber.ROUND_DOWN)}%`, - timestamp: new Date(reward.timestamp) - .toUTCString() - .split(' ') - .slice(0, 4) - .join(' '), -}); - -export const getGraphContentInset = (dataPoints: number[]) => { - let inset = 0; - - if (dataPoints.length <= 10) inset = 20; - - if (dataPoints.length >= 30) inset = 15; - - if (dataPoints.length >= 90) inset = 10; - - if (dataPoints.length >= 180) inset = 5; - - return inset; -}; diff --git a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/VaultHistoricRewardsChart/index.tsx b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/VaultHistoricRewardsChart/index.tsx deleted file mode 100644 index 845ed4a89f48..000000000000 --- a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/VaultHistoricRewardsChart/index.tsx +++ /dev/null @@ -1,242 +0,0 @@ -import React, { useCallback, useMemo, useState } from 'react'; -import { - Dimensions, - GestureResponderEvent, - PanResponder, - View, -} from 'react-native'; -import Text, { - TextColor, - TextVariant, -} from '../../../../../../component-library/components/Texts/Text'; -import { AreaChart } from 'react-native-svg-charts'; -import { useStyles } from '../../../../../hooks/useStyles'; -import styleSheet from './VaultHistoricRewardsChart.styles'; -import { - MOCK_VAULT_APRS, - MOCK_VAULT_REWARDS_ONE_WEEK, - MOCK_VAULT_REWARDS_ONE_YEAR, - parseVaultTimespanAprsResponse, -} from '../mockVaultRewards'; -import ChartTimespanButtonGroup from './ChartTimespanButtonGroup'; -import Tooltip from './Tooltip'; -import DataGradient from './DataGradient'; -import PlotLine from './PlotLine'; -import BigNumber from 'bignumber.js'; -import { - SMALL_DATASET_THRESHOLD, - SMALL_DATASET_SNAP_RATIO, - CHART_BUTTONS, -} from './VaultHistoricRewardsChart.constants'; -import { - calculateSegmentCenters, - formatDailyAprReward, - getGraphContentInset, -} from './VaultHistoricRewardsChart.utils'; - -interface CurrentEarningRateProps { - title: string; - subtitle: string; -} - -const CurrentEarningRate = ({ title, subtitle }: CurrentEarningRateProps) => { - const { styles } = useStyles(styleSheet, {}); - - return ( - - - {title} - - - {subtitle} - - - ); -}; - -/** - * To feel more intuitive while scrolling horizontally, this chart uses a snapping mechanism. - * The chart uses a snap threshold that provides some "give" before transitioning to the next data point. - * This "give" is based on the distance from the center of a segment/data point. - * - * Segment Widths: The chart is divided into equal-width segments, one for each data point. - * Segment Centers: Each data point is associated with a center position within its segment to determine where snapping should occur. - * Snap Threshold: A portion of the segment width (e.g. 25%) that defines how far past a segment's boundary the cursor can go - * before snapping to the next segment - */ -// TODO: Snap threshold shouldn't apply the first and last elements since we have to swipe off screen to reach them. -// TODO: Clicking in between points should default to the next segment. Right now clicking between points does nothing until you start dragging. -// TODO: Replace MOCK values with actual VaultDailyRewards from StakeSDK. -const VaultHistoricRewardsChart = () => { - const { styles } = useStyles(styleSheet, { isSelected: false }); - - // Vault Aprs for 1 day, 1 week, 1 month, 3 months, 6 months, and 1 year. - // Calculated server-side - const parsedVaultTimespanAprs = useMemo( - () => parseVaultTimespanAprsResponse(MOCK_VAULT_APRS), - [], - ); - - // Default at 1 week - const [activeTimespanApr, setActiveTimespanApr] = useState( - parsedVaultTimespanAprs[7], - ); - - const [userSelectedDailyApr, setUserSelectedDailyApr] = useState<{ - apr: string; - timestamp: string; - } | null>(null); - - const [vaultRewardsToDisplay, setVaultRewardsToDisplay] = useState( - MOCK_VAULT_REWARDS_ONE_WEEK, - ); - - const [selectedPointIndex, setSelectedPointIndex] = useState(-1); - - const dataPoints = useMemo( - () => vaultRewardsToDisplay.map(({ daily_apy }) => parseFloat(daily_apy)), - [vaultRewardsToDisplay], - ); - - const doesChartHaveData = dataPoints.length > 0; - - const chartWidth = Dimensions.get('window').width; - - const chartSegmentWidth = useMemo(() => { - const calculatedSegmentWidth = chartWidth / dataPoints.length; - return parseFloat(calculatedSegmentWidth.toFixed(6)); - }, [chartWidth, dataPoints.length]); - - const segmentCenters = useMemo( - () => calculateSegmentCenters(dataPoints, chartSegmentWidth), - [chartSegmentWidth, dataPoints], - ); - - const handleTimespanPressed = (numDaysToDisplay: number) => { - setVaultRewardsToDisplay( - MOCK_VAULT_REWARDS_ONE_YEAR.slice(-numDaysToDisplay), - ); - setActiveTimespanApr(parsedVaultTimespanAprs[numDaysToDisplay]); - // Reset selected position when switching timespan - setSelectedPointIndex(-1); - }; - - // Determines when the cursor should "snap" (or jump to) the next point. - const snapThreshold = useMemo( - () => - chartSegmentWidth * - (dataPoints.length <= SMALL_DATASET_THRESHOLD - ? SMALL_DATASET_SNAP_RATIO - : 0), - [dataPoints.length, chartSegmentWidth], - ); - - const graphInset = useMemo( - () => getGraphContentInset(dataPoints), - [dataPoints], - ); - - const updateSelection = useCallback( - (index: number) => { - setSelectedPointIndex(index); - const activeVaultDailyReward = vaultRewardsToDisplay[index]; - setUserSelectedDailyApr(formatDailyAprReward(activeVaultDailyReward)); - }, - [vaultRewardsToDisplay], - ); - - const updatePosition = useCallback( - (x: number) => { - if (x === -1) { - setSelectedPointIndex(-1); - setUserSelectedDailyApr(null); - return; - } - - // Find the closest segment center to the current touch position - let closestIndex = 0; - let minDistance = Infinity; - - segmentCenters.forEach((center, index) => { - const distance = Math.abs(x - center); - if (distance < minDistance) { - closestIndex = index; - minDistance = distance; - } - }); - - // Ensure snapping respects the snap threshold - // Only snap for small datasets - if ( - minDistance <= snapThreshold || - dataPoints.length > SMALL_DATASET_THRESHOLD - ) { - updateSelection(closestIndex); - } - }, - [segmentCenters, snapThreshold, updateSelection, dataPoints.length], - ); - - const panResponder = useMemo( - () => - PanResponder.create({ - onStartShouldSetPanResponder: () => true, - onStartShouldSetPanResponderCapture: () => true, - onMoveShouldSetPanResponder: () => true, - onMoveShouldSetPanResponderCapture: () => true, - onPanResponderTerminationRequest: () => true, - onPanResponderGrant: (evt: GestureResponderEvent) => { - updatePosition(evt.nativeEvent.locationX); - }, - onPanResponderMove: (evt: GestureResponderEvent) => { - updatePosition(evt.nativeEvent.locationX); - }, - onPanResponderRelease: () => { - updatePosition(-1); - }, - }), - [updatePosition], - ); - - return ( - - - - - - - {doesChartHaveData && } - - - - - ); -}; - -export default VaultHistoricRewardsChart; diff --git a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/index.tsx b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/index.tsx index bbdc176fc002..574d841e6724 100644 --- a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/index.tsx +++ b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/index.tsx @@ -1,4 +1,4 @@ -import React, { useRef } from 'react'; +import React, { useMemo, useRef, useState } from 'react'; import BottomSheet, { BottomSheetRef, } from '../../../../../component-library/components/BottomSheets/BottomSheet'; @@ -20,8 +20,16 @@ import { useNavigation } from '@react-navigation/native'; import { POOLED_STAKING_FAQ_URL } from '../../constants'; import styleSheet from './PoolStakingLearnMoreModal.styles'; import { useStyles } from '../../../../hooks/useStyles'; -import VaultHistoricRewardsChart from './VaultHistoricRewardsChart'; import { MetaMetricsEvents, useMetrics } from '../../../../hooks/useMetrics'; +import useVaultApys from '../../hooks/useVaultApys'; +import InteractiveTimespanChart from './InteractiveTimespanChart'; +import BigNumber from 'bignumber.js'; +import { + formatChartDate, + parseVaultTimespanAprsResponse, +} from './InteractiveTimespanChart/InteractiveTimespanChart.utils'; +import useVaultAprs from '../../hooks/useVaultAprs'; +import { strings } from '../../../../../../locales/i18n'; // TODO: Add Tests // TODO: Make sure heading is aligned on Android devices. @@ -31,19 +39,21 @@ const BodyText = () => { return ( - Stake any amount of ETH.{' '} - No minimum required. + {strings('stake.stake_any_amount_of_eth')}{' '} + + {strings('stake.no_minimum_required')} + - Earn ETH rewards.{' '} + {strings('stake.earn_eth_rewards')}{' '} - Start earning as soon as you stake. Rewards compound automatically. + {strings('stake.earn_eth_rewards_description')} - Flexible unstaking.{' '} + {strings('stake.flexible_unstaking')}{' '} - Unstake anytime. Typically takes up to 11 days to process. + {strings('stake.flexible_unstaking_description')} { color={TextColor.Alternative} style={styles.italicText} > - Staking does not guarantee rewards, and involves risks including a loss - of funds. + {strings('stake.disclaimer')} ); }; -// TODO: Replace hardcoded text const PoolStakingLearnMoreModal = () => { const { styles } = useStyles(styleSheet, {}); @@ -68,6 +76,21 @@ const PoolStakingLearnMoreModal = () => { const sheetRef = useRef(null); + const { vaultApys, isLoadingVaultApys } = useVaultApys(); + + const { vaultAprs, isLoadingVaultAprs } = useVaultAprs(); + + // Vault Aprs for 1 day, 1 week, 1 month, 3 months, 6 months, and 1 year. + // Calculated server-side + const parsedVaultTimespanAprs = useMemo(() => { + if (isLoadingVaultAprs) return; + return parseVaultTimespanAprsResponse(vaultAprs); + }, [isLoadingVaultAprs, vaultAprs]); + + const [activeTimespanApr, setActiveTimespanApr] = useState( + parsedVaultTimespanAprs?.[7], + ); + const handleClose = () => { sheetRef.current?.onCloseBottomSheet(); }; @@ -94,33 +117,55 @@ const PoolStakingLearnMoreModal = () => { const footerButtons: ButtonProps[] = [ { variant: ButtonVariants.Secondary, - label: ( - - Learn more - - ), + label: strings('stake.learn_more'), size: ButtonSize.Lg, + labelTextVariant: TextVariant.BodyMDMedium, onPress: redirectToLearnMore, }, { variant: ButtonVariants.Primary, - label: ( - - Got it - - ), + label: strings('stake.got_it'), + labelTextVariant: TextVariant.BodyMDMedium, size: ButtonSize.Lg, onPress: handleClose, }, ]; + const handleTimespanPressed = (numDataPointsToDisplay: number) => { + setActiveTimespanApr(parsedVaultTimespanAprs?.[numDataPointsToDisplay]); + }; + return ( - Stake ETH and earn + + {strings('stake.stake_eth_and_earn')} + - + {!isLoadingVaultApys && + Boolean(vaultApys.length) && + activeTimespanApr && ( + new BigNumber(point.daily_apy).toNumber()} + defaultTitle={`${new BigNumber(activeTimespanApr.apr).toFixed( + 2, + )}% ${strings('stake.apr')}`} + defaultSubtitle={activeTimespanApr.label} + titleAccessor={(point) => + `${new BigNumber(point.daily_apy).toFixed(2)}% ${strings( + 'stake.apr', + )}` + } + subtitleAccessor={(point) => formatChartDate(point.timestamp)} + onTimespanPressed={handleTimespanPressed} + graphOptions={{ + insetTop: activeTimespanApr.numDays <= 10 ? 20 : 10, + insetBottom: activeTimespanApr.numDays <= 10 ? 20 : 10, + }} + /> + )} { - // TODO: Replace hardcoded strings - const numDaysMap: Record< - keyof typeof MOCK_VAULT_APRS, - { numDays: number; label: string } - > = { - oneDay: { numDays: 1, label: 'Today' }, - oneWeek: { numDays: 7, label: '1 week average' }, - oneMonth: { numDays: 30, label: '1 month average' }, - threeMonths: { numDays: 90, label: '3 month average' }, - sixMonths: { numDays: 180, label: '6 month average' }, - oneYear: { numDays: 365, label: '1 year average' }, - }; - - return Object.entries(vaultTimespanAprs).reduce< - Record - >((map, [key, value]) => { - const numDaysMapEntry = numDaysMap[key as keyof typeof numDaysMap]; - map[numDaysMapEntry.numDays] = { apr: value, ...numDaysMapEntry }; - return map; - }, {}); -}; diff --git a/app/components/UI/Stake/hooks/useVaultAprs.tsx b/app/components/UI/Stake/hooks/useVaultAprs.tsx new file mode 100644 index 000000000000..cc07fd9d26f8 --- /dev/null +++ b/app/components/UI/Stake/hooks/useVaultAprs.tsx @@ -0,0 +1,56 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useStakeContext } from './useStakeContext'; +import { hexToNumber } from '@metamask/utils'; +import { useDispatch, useSelector } from 'react-redux'; +import { selectChainId } from '../../../../selectors/networkController'; +import { + selectVaultAprs, + setVaultAprs, +} from '../../../../core/redux/slices/staking'; + +// TODO: Add tests +const useVaultAprs = () => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const dispatch = useDispatch(); + + const { vaultAprs } = useSelector(selectVaultAprs); + const chainId = useSelector(selectChainId); + + const { stakingApiService } = useStakeContext(); + + const fetchVaultAprs = useCallback(async () => { + if (!stakingApiService) return; + + setIsLoading(true); + setError(null); + + try { + const numericChainId = hexToNumber(chainId); + const vaultAprsResponse = await stakingApiService.getVaultAprs( + numericChainId, + ); + // TODO: Determine how we should refresh this value. + dispatch(setVaultAprs(vaultAprsResponse)); + } catch (err) { + setError('Failed to fetch vault APRs'); + } finally { + setIsLoading(false); + } + }, [chainId, dispatch, stakingApiService]); + + useEffect(() => { + if (Object.keys(vaultAprs).length) return; + fetchVaultAprs(); + }, [fetchVaultAprs, vaultAprs]); + + return { + vaultAprs, + fetchVaultAprs, + isLoadingVaultAprs: isLoading, + error, + }; +}; + +export default useVaultAprs; diff --git a/app/components/UI/Stake/hooks/useVaultApys.tsx b/app/components/UI/Stake/hooks/useVaultApys.tsx new file mode 100644 index 000000000000..610fbb5103d4 --- /dev/null +++ b/app/components/UI/Stake/hooks/useVaultApys.tsx @@ -0,0 +1,64 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useStakeContext } from './useStakeContext'; +import { hexToNumber } from '@metamask/utils'; +import { useDispatch, useSelector } from 'react-redux'; +import { selectChainId } from '../../../../selectors/networkController'; +import { + selectVaultApys, + setVaultApys, + VaultApys, +} from '../../../../core/redux/slices/staking'; + +const useVaultApys = () => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const dispatch = useDispatch(); + + const { vaultApys } = useSelector(selectVaultApys); + const chainId = useSelector(selectChainId); + + const { stakingApiService } = useStakeContext(); + + const fetchVaultApys = useCallback(async () => { + if (!stakingApiService) return; + + setIsLoading(true); + setError(null); + + try { + const numericChainId = hexToNumber(chainId); + const vaultApysResponse = await stakingApiService.getVaultDailyRewards( + numericChainId, + 365, + 'desc', + ); + + // TODO: Fix type error before pushing + // @ts-expect-error temp before updating stake-sdk to latest VaultApys endpoint + const reversedVaultApys = vaultApysResponse?.reverse(); + + // TODO: Determine how we should refresh this value. + // TEMP: TODO: Update type after updating stake-sdk + dispatch(setVaultApys(reversedVaultApys as unknown as VaultApys)); + } catch (err) { + setError('Failed to fetch vault APYs'); + } finally { + setIsLoading(false); + } + }, [chainId, dispatch, stakingApiService]); + + useEffect(() => { + if (Object.keys(vaultApys).length) return; + fetchVaultApys(); + }, [fetchVaultApys, vaultApys]); + + return { + vaultApys, + fetchVaultApys, + isLoadingVaultApys: isLoading, + error, + }; +}; + +export default useVaultApys; diff --git a/app/core/redux/slices/staking/index.ts b/app/core/redux/slices/staking/index.ts index 99ddc1c6ae4d..8bc63ef3f56c 100644 --- a/app/core/redux/slices/staking/index.ts +++ b/app/core/redux/slices/staking/index.ts @@ -1,12 +1,25 @@ -import type { PooledStake, VaultData } from '@metamask/stake-sdk'; +import type { + PooledStake, + VaultData, + VaultAprs, + VaultDailyReward, +} from '@metamask/stake-sdk'; import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; import { createSelector } from 'reselect'; import type { RootState } from '../../../../reducers'; +// TEMP: Until stake-sdk is updated with latest version. +export type VaultApys = Omit< + VaultDailyReward, + 'earned_assets_wei' | 'total_assets_wei' +>[]; + interface PooledStakingState { pooledStakes: PooledStake; exchangeRate: string; vaultData: VaultData; + vaultAprs: VaultAprs; + vaultApys: VaultApys; isEligible: boolean; } @@ -14,6 +27,8 @@ export const initialState: PooledStakingState = { pooledStakes: {} as PooledStake, exchangeRate: '', vaultData: {} as VaultData, + vaultAprs: {} as VaultAprs, + vaultApys: [], isEligible: false, }; @@ -36,6 +51,12 @@ const slice = createSlice({ setVaultData: (state, action: PayloadAction) => { state.vaultData = action.payload; }, + setVaultAprs: (state, action: PayloadAction) => { + state.vaultAprs = action.payload; + }, + setVaultApys: (state, action: PayloadAction) => { + state.vaultApys = action.payload; + }, setStakingEligibility: (state, action: PayloadAction) => { state.isEligible = action.payload; }, @@ -44,7 +65,13 @@ const slice = createSlice({ const { actions, reducer } = slice; export default reducer; -export const { setPooledStakes, setVaultData, setStakingEligibility } = actions; +export const { + setPooledStakes, + setVaultData, + setVaultAprs, + setVaultApys, + setStakingEligibility, +} = actions; // Selectors const selectPooledStakingState = (state: RootState) => state.staking; @@ -64,6 +91,20 @@ export const selectVaultData = createSelector( }), ); +export const selectVaultAprs = createSelector( + selectPooledStakingState, + (stakingStake) => ({ + vaultAprs: stakingStake.vaultAprs, + }), +); + +export const selectVaultApys = createSelector( + selectPooledStakingState, + (stakingStake) => ({ + vaultApys: stakingStake.vaultApys, + }), +); + export const selectStakingEligibility = createSelector( selectPooledStakingState, (stakingState) => ({ diff --git a/locales/languages/en.json b/locales/languages/en.json index aa11c8578ab8..cb7fecd07082 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -3444,12 +3444,12 @@ "not_enough_eth": "Not enough ETH", "balance":"Balance", "stake_eth_and_earn":"Stake ETH and earn", - "stake_any_amount_of_eth":"Stake any amount of ETH", + "stake_any_amount_of_eth":"Stake any amount of ETH.", "no_minimum_required":"No minimum required.", - "earn_eth_rewards":"Earn ETH rewards", + "earn_eth_rewards":"Earn ETH rewards.", "earn_eth_rewards_description":"Start earning as soon as you stake. Rewards compound automatically.", - "flexible_unstaking":"Flexible unstaking", - "flexible_unstaking_description":"Unstake anytime. Typically takes less than 3 days, but can take up to 11 days to process.", + "flexible_unstaking":"Flexible unstaking.", + "flexible_unstaking_description":"Unstake anytime. Typically takes up to 11 days to process.", "disclaimer":"Staking does not guarantee rewards, and involves risks including a loss of funds.", "learn_more":"Learn more", "got_it":"Got it", @@ -3507,7 +3507,22 @@ "estimated_unstaking_time": "1 to 11 days", "proceed_anyway": "Proceed anyway", "gas_cost_impact": "Gas cost impact", - "gas_cost_impact_warning": "Warning: the transaction gas cost will account for more than 30% of your deposit." + "gas_cost_impact_warning": "Warning: the transaction gas cost will account for more than 30% of your deposit.", + "apr": "APR", + "interactive_chart": { + "timespan_buttons": { + "7D": "7D", + "1M": "1M", + "3M": "3M", + "6M": "6M" + } + }, + "today": "Today", + "one_week_average": "1 week average", + "one_month_average": "1 month average", + "three_month_average": "3 month average", + "six_month_average": "6 month average", + "one_year_average": "1 year average" }, "default_settings": { "title": "Your Wallet is ready",