diff --git a/app/components/Nav/App/App.test.tsx b/app/components/Nav/App/App.test.tsx index ea5a3bce5d1..0ca83a2465b 100644 --- a/app/components/Nav/App/App.test.tsx +++ b/app/components/Nav/App/App.test.tsx @@ -890,4 +890,45 @@ describe('App', () => { }); }); }); + + describe('route registration', () => { + const renderAppWithRouteState = ( + routeState: PartialState, + ) => { + const mockStore = configureMockStore(); + const store = mockStore(initialState); + + const Providers = ({ children }: { children: React.ReactElement }) => ( + + + + {children} + + + + ); + + return render(, { wrapper: Providers }); + }; + + it('registers the eligibility failed modal route', async () => { + const routeState = { + index: 0, + routes: [ + { + name: Routes.MODAL.ROOT_MODAL_FLOW, + params: { + screen: Routes.SHEET.ELIGIBILITY_FAILED_MODAL, + }, + }, + ], + }; + + const { getByTestId } = renderAppWithRouteState(routeState); + + await waitFor(() => { + expect(getByTestId('eligibility-failed-modal')).toBeTruthy(); + }); + }); + }); }); diff --git a/app/components/Nav/App/App.tsx b/app/components/Nav/App/App.tsx index 106be80d5e3..c28a100cb10 100644 --- a/app/components/Nav/App/App.tsx +++ b/app/components/Nav/App/App.tsx @@ -128,6 +128,7 @@ import SkipAccountSecurityModal from '../../UI/SkipAccountSecurityModal'; import SuccessErrorSheet from '../../Views/SuccessErrorSheet'; import ConfirmTurnOnBackupAndSyncModal from '../../UI/Identity/ConfirmTurnOnBackupAndSyncModal/ConfirmTurnOnBackupAndSyncModal'; import AddNewAccountBottomSheet from '../../Views/AddNewAccount/AddNewAccountBottomSheet'; +import EligibilityFailedModal from '../../UI/Ramp/components/EligibilityFailedModal'; import SwitchAccountTypeModal from '../../Views/confirmations/components/modals/switch-account-type-modal'; import { AccountDetails } from '../../Views/MultichainAccounts/AccountDetails/AccountDetails'; import { AccountGroupDetails } from '../../Views/MultichainAccounts/AccountGroupDetails/AccountGroupDetails'; @@ -400,6 +401,10 @@ const RootModalFlow = (props: RootModalFlowProps) => ( name={Routes.SHEET.SUCCESS_ERROR_SHEET} component={SuccessErrorSheet} /> + ({ })); describe('PredictDetailsChart', () => { + const BASE_TIMESTAMP = 1_700_000_000_000; + const FORTY_FIVE_MINUTES_IN_MS = 45 * 60 * 1000; + const TWELVE_HOURS_IN_MS = 12 * 60 * 60 * 1000; + const TWENTY_FOUR_HOURS_IN_MS = 24 * 60 * 60 * 1000; + const mockSingleSeries: ChartSeries[] = [ { label: 'Outcome 1', @@ -175,7 +180,27 @@ describe('PredictDetailsChart', () => { }); it('highlights selected timeframe', () => { - const { getByText } = setupTest({ selectedTimeframe: '1d' }); + const daySpanSeries: ChartSeries[] = [ + { + label: 'Extended Outcome', + color: '#28C76F', + data: [ + { timestamp: BASE_TIMESTAMP, value: 0.4 }, + { + timestamp: BASE_TIMESTAMP + 18 * 60 * 60 * 1000, + value: 0.5, + }, + { + timestamp: BASE_TIMESTAMP + 36 * 60 * 60 * 1000, + value: 0.6, + }, + ], + }, + ]; + const { getByText } = setupTest({ + data: daySpanSeries, + selectedTimeframe: '1d', + }); expect(getByText('1D')).toBeOnTheScreen(); }); @@ -196,14 +221,124 @@ describe('PredictDetailsChart', () => { expect(queryByText('Loading price history...')).not.toBeOnTheScreen(); }); - it('renders custom empty label when provided', () => { + it('returns null when no data provided even with custom empty label', () => { const customLabel = 'No data available'; - const { getByText } = setupTest({ + const { queryByTestId, queryByText } = setupTest({ data: [{ label: 'Empty', color: '#000', data: [] }], emptyLabel: customLabel, }); - expect(getByText(customLabel)).toBeOnTheScreen(); + expect(queryByTestId('line-chart')).toBeNull(); + expect(queryByText(customLabel)).toBeNull(); + }); + }); + + describe('Timeframe filtering', () => { + it('returns null when all series fall outside selected timeframe', () => { + const dataOutsideTimeframe: ChartSeries[] = [ + { + label: 'Outcome Short', + color: '#123456', + data: [ + { timestamp: BASE_TIMESTAMP, value: 0.5 }, + { timestamp: BASE_TIMESTAMP + 30 * 60 * 1000, value: 0.6 }, + ], + }, + ]; + + const { queryByTestId, queryByText } = setupTest({ + data: dataOutsideTimeframe, + selectedTimeframe: '1d', + isLoading: false, + }); + + expect(queryByTestId('line-chart')).toBeNull(); + expect(queryByText('1D')).toBeNull(); + }); + + it('omits series that do not span selected timeframe', () => { + const longSpanSeriesA: ChartSeries = { + label: 'Outcome Long A', + color: '#28C76F', + data: [ + { timestamp: BASE_TIMESTAMP, value: 0.4 }, + { + timestamp: BASE_TIMESTAMP + TWELVE_HOURS_IN_MS, + value: 0.5, + }, + { + timestamp: BASE_TIMESTAMP + TWENTY_FOUR_HOURS_IN_MS, + value: 0.6, + }, + ], + }; + const longSpanSeriesB: ChartSeries = { + label: 'Outcome Long B', + color: '#4459FF', + data: [ + { timestamp: BASE_TIMESTAMP, value: 0.3 }, + { + timestamp: BASE_TIMESTAMP + TWENTY_FOUR_HOURS_IN_MS, + value: 0.35, + }, + { + timestamp: BASE_TIMESTAMP + 2 * TWENTY_FOUR_HOURS_IN_MS, + value: 0.38, + }, + ], + }; + const shortSpanSeries: ChartSeries = { + label: 'Outcome Short', + color: '#CA3542', + data: [ + { timestamp: BASE_TIMESTAMP, value: 0.2 }, + { + timestamp: BASE_TIMESTAMP + 30 * 60 * 1000, + value: 0.25, + }, + ], + }; + + const { getAllByTestId, getByText, queryByText } = setupTest({ + data: [longSpanSeriesA, longSpanSeriesB, shortSpanSeries], + selectedTimeframe: '1d', + }); + + expect(getAllByTestId('line-chart').length).toBeGreaterThan(0); + expect(getByText(/Outcome Long A/)).toBeOnTheScreen(); + expect(getByText(/Outcome Long B/)).toBeOnTheScreen(); + expect(queryByText('Outcome Short')).toBeNull(); + }); + + it('renders series that cover at least half of selected timeframe span', () => { + const halfDaySpanSeries: ChartSeries = { + label: 'Outcome Half Day', + color: '#FFAA00', + data: [ + { timestamp: BASE_TIMESTAMP, value: 0.45 }, + { + timestamp: BASE_TIMESTAMP + TWELVE_HOURS_IN_MS, + value: 0.5, + }, + ], + }; + + const { getByTestId } = setupTest({ + data: [halfDaySpanSeries], + selectedTimeframe: '1d', + }); + + expect(getByTestId('line-chart')).toBeOnTheScreen(); + }); + + it('hides chart when series contain no data points for selected timeframe', () => { + const { queryByTestId } = setupTest({ + data: [{ label: 'Empty', color: '#000000', data: [] }], + selectedTimeframe: '1d', + isLoading: false, + }); + + expect(queryByTestId('line-chart')).toBeNull(); }); }); @@ -297,12 +432,9 @@ describe('PredictDetailsChart', () => { data: [{ timestamp: 1640995200000, value: 0.5 }], }, ]; - const { getByTestId } = setupTest({ data: singlePointData }); - - const chartData = getByTestId('chart-data'); - const data = JSON.parse(String(chartData.children[0])); + const { queryByTestId } = setupTest({ data: singlePointData }); - expect(data).toEqual([0.5]); + expect(queryByTestId('line-chart')).toBeNull(); }); it('handles empty data array', () => { @@ -313,12 +445,9 @@ describe('PredictDetailsChart', () => { data: [], }, ]; - const { getByTestId } = setupTest({ data: emptyData }); + const { queryByTestId } = setupTest({ data: emptyData }); - const chartData = getByTestId('chart-data'); - const data = JSON.parse(String(chartData.children[0])); - - expect(data).toEqual(expect.arrayContaining([0])); + expect(queryByTestId('line-chart')).toBeNull(); }); it('calculates correct chart bounds with padding', () => { @@ -525,16 +654,22 @@ describe('PredictDetailsChart', () => { label: 'Series A', color: '#4459FF', data: [ - { timestamp: 1, value: 0.3 }, - { timestamp: 2, value: 0.7 }, + { timestamp: BASE_TIMESTAMP, value: 0.3 }, + { + timestamp: BASE_TIMESTAMP + FORTY_FIVE_MINUTES_IN_MS, + value: 0.7, + }, ], }, { label: 'Series B', color: '#FF6B6B', data: [ - { timestamp: 1, value: 0.7 }, - { timestamp: 2, value: 0.3 }, + { timestamp: BASE_TIMESTAMP, value: 0.7 }, + { + timestamp: BASE_TIMESTAMP + FORTY_FIVE_MINUTES_IN_MS, + value: 0.3, + }, ], }, ]; @@ -552,16 +687,22 @@ describe('PredictDetailsChart', () => { label: 'Close A', color: '#4459FF', data: [ - { timestamp: 1, value: 0.5 }, - { timestamp: 2, value: 0.501 }, + { timestamp: BASE_TIMESTAMP, value: 0.5 }, + { + timestamp: BASE_TIMESTAMP + FORTY_FIVE_MINUTES_IN_MS, + value: 0.501, + }, ], }, { label: 'Close B', color: '#FF6B6B', data: [ - { timestamp: 1, value: 0.502 }, - { timestamp: 2, value: 0.503 }, + { timestamp: BASE_TIMESTAMP, value: 0.502 }, + { + timestamp: BASE_TIMESTAMP + FORTY_FIVE_MINUTES_IN_MS, + value: 0.503, + }, ], }, ]; @@ -614,12 +755,24 @@ describe('PredictDetailsChart', () => { { label: 'Blue', color: '#0000FF', - data: [{ timestamp: 1, value: 0.5 }], + data: [ + { timestamp: BASE_TIMESTAMP, value: 0.5 }, + { + timestamp: BASE_TIMESTAMP + FORTY_FIVE_MINUTES_IN_MS, + value: 0.55, + }, + ], }, { label: 'Red', color: '#FF0000', - data: [{ timestamp: 1, value: 0.5 }], + data: [ + { timestamp: BASE_TIMESTAMP, value: 0.5 }, + { + timestamp: BASE_TIMESTAMP + FORTY_FIVE_MINUTES_IN_MS, + value: 0.45, + }, + ], }, ]; @@ -645,24 +798,33 @@ describe('PredictDetailsChart', () => { label: 'Series 1', color: '#4459FF', data: [ - { timestamp: 1, value: 0.5 }, - { timestamp: 2, value: 0.6 }, + { timestamp: BASE_TIMESTAMP, value: 0.5 }, + { + timestamp: BASE_TIMESTAMP + FORTY_FIVE_MINUTES_IN_MS, + value: 0.6, + }, ], }, { label: 'Series 2', color: '#FF6B6B', data: [ - { timestamp: 1, value: 0.3 }, - { timestamp: 2, value: 0.4 }, + { timestamp: BASE_TIMESTAMP, value: 0.3 }, + { + timestamp: BASE_TIMESTAMP + FORTY_FIVE_MINUTES_IN_MS, + value: 0.4, + }, ], }, { label: 'Series 3', color: '#F0B034', data: [ - { timestamp: 1, value: 0.2 }, - { timestamp: 2, value: 0.25 }, + { timestamp: BASE_TIMESTAMP, value: 0.2 }, + { + timestamp: BASE_TIMESTAMP + FORTY_FIVE_MINUTES_IN_MS, + value: 0.25, + }, ], }, ]; diff --git a/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.tsx b/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.tsx index 0aad87d7789..f2fd88fb799 100644 --- a/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.tsx +++ b/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.tsx @@ -30,8 +30,12 @@ import { CHART_CONTENT_INSET, MAX_SERIES, formatPriceHistoryLabel, + getTimeframeDurationMs, } from './utils'; +const MIN_SERIES_POINTS = 2; +const MIN_TIMEFRAME_COVERAGE_RATIO = 0.5; + export interface ChartSeries { label: string; color: string; @@ -275,28 +279,69 @@ const PredictDetailsChart: React.FC = ({ // Limit to MAX_SERIES const seriesToRender = React.useMemo(() => data.slice(0, MAX_SERIES), [data]); - const isSingleSeries = seriesToRender.length === 1; - const isMultipleSeries = seriesToRender.length > 1; + const timeframeDurationMs = React.useMemo( + () => getTimeframeDurationMs(selectedTimeframe), + [selectedTimeframe], + ); + const seriesWithinTimeframe = React.useMemo(() => { + if (timeframeDurationMs === null) { + return seriesToRender; + } + + return seriesToRender.filter((series) => { + if (!series.data?.length) { + return false; + } + + const timestampsInMs = series.data + .map((point) => Number(point.timestamp)) + .filter((timestamp) => Number.isFinite(timestamp)) + .map((timestamp) => + timestamp > 1_000_000_000_000 ? timestamp : timestamp * 1000, + ); + + if (!timestampsInMs.length) { + return false; + } + if (timestampsInMs.length < MIN_SERIES_POINTS) { + return false; + } + + const minTimestamp = Math.min(...timestampsInMs); + const maxTimestamp = Math.max(...timestampsInMs); + const span = maxTimestamp - minTimestamp; + + const coverageRatio = span / timeframeDurationMs; + + return coverageRatio >= MIN_TIMEFRAME_COVERAGE_RATIO; + }); + }, [seriesToRender, timeframeDurationMs]); // Process data with labels const seriesWithLabels = React.useMemo( () => - seriesToRender.map((series) => ({ + seriesWithinTimeframe.map((series) => ({ ...series, data: series.data.map((point) => ({ ...point, label: formatPriceHistoryLabel(point.timestamp, selectedTimeframe), })), })), - [seriesToRender, selectedTimeframe], + [seriesWithinTimeframe, selectedTimeframe], ); + const isSingleSeries = seriesWithLabels.length === 1; + const isMultipleSeries = seriesWithLabels.length > 1; + // Filter out empty series const nonEmptySeries = seriesWithLabels.filter( (series) => series.data.length > 0, ); const hasData = nonEmptySeries.length > 0; + const shouldHideChart = + !isLoading && (!hasData || seriesWithinTimeframe.length === 0); + // Calculate chart bounds const chartValues = hasData ? nonEmptySeries.flatMap((series) => @@ -405,6 +450,10 @@ const PredictDetailsChart: React.FC = ({ [updatePosition], ); + if (shouldHideChart) { + return null; + } + const renderGraph = () => { if (isLoading || !hasData) { return ( diff --git a/app/components/UI/Predict/components/PredictDetailsChart/utils.test.ts b/app/components/UI/Predict/components/PredictDetailsChart/utils.test.ts index ae8fa618349..e17bed9874f 100644 --- a/app/components/UI/Predict/components/PredictDetailsChart/utils.test.ts +++ b/app/components/UI/Predict/components/PredictDetailsChart/utils.test.ts @@ -5,6 +5,7 @@ import { CHART_HEIGHT, CHART_CONTENT_INSET, MAX_SERIES, + getTimeframeDurationMs, formatPriceHistoryLabel, formatTickValue, } from './utils'; @@ -38,6 +39,35 @@ describe('PredictDetailsChart utils', () => { }); }); + describe('getTimeframeDurationMs', () => { + it.each([ + [PredictPriceHistoryInterval.ONE_HOUR, 60 * 60 * 1000], + [PredictPriceHistoryInterval.SIX_HOUR, 6 * 60 * 60 * 1000], + [PredictPriceHistoryInterval.ONE_DAY, 24 * 60 * 60 * 1000], + [PredictPriceHistoryInterval.ONE_WEEK, 7 * 24 * 60 * 60 * 1000], + [PredictPriceHistoryInterval.ONE_MONTH, 30 * 24 * 60 * 60 * 1000], + ])( + 'returns expected duration for %s interval', + (interval: PredictPriceHistoryInterval, expectedDuration: number) => { + const result = getTimeframeDurationMs(interval); + + expect(result).toBe(expectedDuration); + }, + ); + + it('returns null for MAX interval', () => { + const result = getTimeframeDurationMs(PredictPriceHistoryInterval.MAX); + + expect(result).toBeNull(); + }); + + it('returns null for unknown interval', () => { + const result = getTimeframeDurationMs('unknown-interval'); + + expect(result).toBeNull(); + }); + }); + describe('formatPriceHistoryLabel', () => { beforeEach(() => { jest.useFakeTimers(); diff --git a/app/components/UI/Predict/components/PredictDetailsChart/utils.ts b/app/components/UI/Predict/components/PredictDetailsChart/utils.ts index 37f80c73b03..1edb71cdd3b 100644 --- a/app/components/UI/Predict/components/PredictDetailsChart/utils.ts +++ b/app/components/UI/Predict/components/PredictDetailsChart/utils.ts @@ -1,6 +1,11 @@ import { curveCatmullRom } from 'd3-shape'; import { PredictPriceHistoryInterval } from '../../types'; +const HOUR_IN_MS = 60 * 60 * 1000; +const DAY_IN_MS = 24 * HOUR_IN_MS; +const WEEK_IN_MS = 7 * DAY_IN_MS; +const MONTH_IN_MS = 30 * DAY_IN_MS; + export const DEFAULT_EMPTY_LABEL = ''; export const LINE_CURVE = curveCatmullRom.alpha(0.2); export const CHART_HEIGHT = 192; @@ -12,6 +17,26 @@ export const CHART_CONTENT_INSET = { }; export const MAX_SERIES = 3; +export const getTimeframeDurationMs = ( + interval: PredictPriceHistoryInterval | string, +): number | null => { + switch (interval) { + case PredictPriceHistoryInterval.ONE_HOUR: + return HOUR_IN_MS; + case PredictPriceHistoryInterval.SIX_HOUR: + return 6 * HOUR_IN_MS; + case PredictPriceHistoryInterval.ONE_DAY: + return DAY_IN_MS; + case PredictPriceHistoryInterval.ONE_WEEK: + return WEEK_IN_MS; + case PredictPriceHistoryInterval.ONE_MONTH: + return MONTH_IN_MS; + case PredictPriceHistoryInterval.MAX: + default: + return null; + } +}; + export const formatPriceHistoryLabel = ( timestamp: number, interval: PredictPriceHistoryInterval | string, diff --git a/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.tsx b/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.tsx index f00adfeec75..d6f8e0bbde9 100644 --- a/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.tsx +++ b/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.tsx @@ -149,10 +149,17 @@ const PredictMarketSingle: React.FC = ({ const getYesPercentage = (): number => { const prices = getOutcomePrices(); - if (prices.length > 0) { - return Math.round(prices[0] * 100); + if (prices.length === 0) { + return 0; } - return 0; + + const yesPrice = Number(prices[0]); + + if (!Number.isFinite(yesPrice)) { + return 0; + } + + return Math.round(yesPrice * 100); }; const getTitle = (): string => outcome.title ?? 'Unknown Market'; @@ -236,7 +243,7 @@ const PredictMarketSingle: React.FC = ({ width={ButtonWidthTypes.Full} label={ - {strings('predict.buy_yes')} + {outcome.tokens[0].title} } onPress={() => handleBuy(outcome.tokens[0])} @@ -248,7 +255,7 @@ const PredictMarketSingle: React.FC = ({ width={ButtonWidthTypes.Full} label={ - {strings('predict.buy_no')} + {outcome.tokens[1].title} } onPress={() => handleBuy(outcome.tokens[1])} diff --git a/app/components/UI/Predict/hooks/usePredictDepositToasts.tsx b/app/components/UI/Predict/hooks/usePredictDepositToasts.tsx index 2e638cdd4fd..433b708c8ec 100644 --- a/app/components/UI/Predict/hooks/usePredictDepositToasts.tsx +++ b/app/components/UI/Predict/hooks/usePredictDepositToasts.tsx @@ -7,7 +7,7 @@ import { usePredictBalance } from './usePredictBalance'; import { POLYMARKET_PROVIDER_ID } from '../providers/polymarket/constants'; import { useNavigation } from '@react-navigation/native'; import Routes from '../../../../constants/navigation/Routes'; -import { formatPrice } from '../utils/format'; +import { formatPrice, calculateNetAmount } from '../utils/format'; import { getEvmAccountFromSelectedAccountGroup } from '../utils/accounts'; import { useSelector } from 'react-redux'; import { selectPredictPendingDepositByAddress } from '../selectors/predictController'; @@ -52,10 +52,18 @@ export const usePredictDepositToasts = ({ description: strings('predict.deposit.account_ready_description', { amount: '{amount}', }), - getAmount: (transactionMeta) => - formatPrice(transactionMeta.metamaskPay?.totalFiat ?? 0, { - maximumDecimals: 2, - }) ?? 'Balance', + getAmount: (transactionMeta) => { + const netAmount = calculateNetAmount({ + totalFiat: transactionMeta.metamaskPay?.totalFiat, + bridgeFeeFiat: transactionMeta.metamaskPay?.bridgeFeeFiat, + networkFeeFiat: transactionMeta.metamaskPay?.networkFeeFiat, + }); + return ( + formatPrice(netAmount, { + maximumDecimals: 2, + }) ?? 'Balance' + ); + }, }, errorToastConfig: { title: strings('predict.deposit.error_title'), diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts index f3a6bba63cb..7f144616ec4 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts @@ -610,10 +610,14 @@ export class PolymarketProvider implements PredictProvider { address, positions, claimable, + marketId, + outcomeId, }: { address: string; positions: PredictPosition[]; claimable: boolean; + marketId?: string; + outcomeId?: string; }): PredictPosition[] { const optimisticUpdates = this.#optimisticPositionUpdatesByAddress.get(address); @@ -642,6 +646,11 @@ export class PolymarketProvider implements PredictProvider { return; } + // Check if this update matches the query filters + const matchesFilter = + (!outcomeId || update.outcomeTokenId === outcomeId) && + (!marketId || outcomeId || update.marketId === marketId); + const apiPositionIndex = result.findIndex( (p) => p.outcomeTokenId === outcomeTokenId, ); @@ -664,12 +673,16 @@ export class PolymarketProvider implements PredictProvider { expectedSize: update.expectedSize, }, ); - } else if (update.optimisticPosition) { - // API not yet updated, use optimistic position + } else if ( + update.optimisticPosition && + !claimable && + matchesFilter + ) { + // API not yet updated, use optimistic position (only if matches filter) result[apiPositionIndex] = update.optimisticPosition; } - } else if (update.optimisticPosition && !claimable) { - // New position not in API yet, add optimistic position + } else if (update.optimisticPosition && !claimable && matchesFilter) { + // New position not in API yet, add optimistic position (only if matches filter) result.push(update.optimisticPosition); } break; @@ -760,6 +773,8 @@ export class PolymarketProvider implements PredictProvider { address, positions: parsedPositions, claimable, + marketId, + outcomeId, }); return positionsWithOptimisticUpdates; diff --git a/app/components/UI/Predict/utils/format.test.ts b/app/components/UI/Predict/utils/format.test.ts index 2cf4d0f5754..6034489fc05 100644 --- a/app/components/UI/Predict/utils/format.test.ts +++ b/app/components/UI/Predict/utils/format.test.ts @@ -8,6 +8,7 @@ import { getRecurrence, formatCents, formatPositionSize, + calculateNetAmount, } from './format'; import { Recurrence, PredictSeries } from '../types'; @@ -1255,4 +1256,201 @@ describe('format utils', () => { expect(result).toBe('5'); }); }); + + describe('calculateNetAmount', () => { + it('calculates net amount by subtracting fees from total', () => { + const params = { + totalFiat: '10.00', + bridgeFeeFiat: '0.50', + networkFeeFiat: '0.25', + }; + + const result = calculateNetAmount(params); + + expect(result).toBe('9.25'); + }); + + it('calculates net amount with high precision decimal values', () => { + const params = { + totalFiat: '1.04361142938843253220839271649743403', + bridgeFeeFiat: '0.036399', + networkFeeFiat: '0.008024478270232503211154803918368', + }; + + const result = calculateNetAmount(params); + + expect(result).toBe('0.9991879511181999'); + }); + + it('returns "0" when total equals sum of fees', () => { + const params = { + totalFiat: '1.00', + bridgeFeeFiat: '0.50', + networkFeeFiat: '0.50', + }; + + const result = calculateNetAmount(params); + + expect(result).toBe('0'); + }); + + it('returns "0" when fees exceed total', () => { + const params = { + totalFiat: '1.00', + bridgeFeeFiat: '0.75', + networkFeeFiat: '0.50', + }; + + const result = calculateNetAmount(params); + + expect(result).toBe('0'); + }); + + it('returns "0" when totalFiat is undefined', () => { + const params = { + bridgeFeeFiat: '0.50', + networkFeeFiat: '0.25', + }; + + const result = calculateNetAmount(params); + + expect(result).toBe('0'); + }); + + it('treats missing bridgeFeeFiat as zero', () => { + const params = { + totalFiat: '10.00', + networkFeeFiat: '0.25', + }; + + const result = calculateNetAmount(params); + + expect(result).toBe('9.75'); + }); + + it('treats missing networkFeeFiat as zero', () => { + const params = { + totalFiat: '10.00', + bridgeFeeFiat: '0.50', + }; + + const result = calculateNetAmount(params); + + expect(result).toBe('9.5'); + }); + + it('returns full total when both fees are missing', () => { + const params = { + totalFiat: '10.00', + }; + + const result = calculateNetAmount(params); + + expect(result).toBe('10'); + }); + + it('returns "0" when all parameters are undefined', () => { + const params = {}; + + const result = calculateNetAmount(params); + + expect(result).toBe('0'); + }); + + it('returns "0" when totalFiat is invalid string', () => { + const params = { + totalFiat: 'invalid', + bridgeFeeFiat: '0.50', + networkFeeFiat: '0.25', + }; + + const result = calculateNetAmount(params); + + expect(result).toBe('0'); + }); + + it('returns "0" when bridgeFeeFiat is invalid string', () => { + const params = { + totalFiat: '10.00', + bridgeFeeFiat: 'invalid', + networkFeeFiat: '0.25', + }; + + const result = calculateNetAmount(params); + + expect(result).toBe('0'); + }); + + it('returns "0" when networkFeeFiat is invalid string', () => { + const params = { + totalFiat: '10.00', + bridgeFeeFiat: '0.50', + networkFeeFiat: 'invalid', + }; + + const result = calculateNetAmount(params); + + expect(result).toBe('0'); + }); + + it('calculates correctly when fees are zero', () => { + const params = { + totalFiat: '10.00', + bridgeFeeFiat: '0', + networkFeeFiat: '0', + }; + + const result = calculateNetAmount(params); + + expect(result).toBe('10'); + }); + + it('calculates correctly when only bridge fee exists', () => { + const params = { + totalFiat: '10.00', + bridgeFeeFiat: '2.50', + networkFeeFiat: '0', + }; + + const result = calculateNetAmount(params); + + expect(result).toBe('7.5'); + }); + + it('calculates correctly when only network fee exists', () => { + const params = { + totalFiat: '10.00', + bridgeFeeFiat: '0', + networkFeeFiat: '3.25', + }; + + const result = calculateNetAmount(params); + + expect(result).toBe('6.75'); + }); + + it('handles very small decimal amounts precisely', () => { + const params = { + totalFiat: '0.001', + bridgeFeeFiat: '0.0001', + networkFeeFiat: '0.0002', + }; + + const result = calculateNetAmount(params); + + expect(result).toBe('0.0007'); + }); + + it('handles large amounts correctly', () => { + const params = { + totalFiat: '1000000.00', + bridgeFeeFiat: '50.00', + networkFeeFiat: '25.00', + }; + + const result = calculateNetAmount(params); + + expect(result).toBe('999925'); + }); + }); }); diff --git a/app/components/UI/Predict/utils/format.ts b/app/components/UI/Predict/utils/format.ts index cb69ec56caf..5e033b1758b 100644 --- a/app/components/UI/Predict/utils/format.ts +++ b/app/components/UI/Predict/utils/format.ts @@ -77,6 +77,53 @@ export const formatPrice = ( }); }; +/** + * Calculates the net amount after deducting bridge and network fees from the total fiat amount + * @param params - Object containing fee and total amount information + * @param params.totalFiat - Total fiat amount as string + * @param params.bridgeFeeFiat - Bridge fee amount as string + * @param params.networkFeeFiat - Network fee amount as string + * @returns Net amount as string after deducting fees, or "0" if calculation fails + * @example + * calculateNetAmount({ + * totalFiat: "1.04361142938843253220839271649743403", + * bridgeFeeFiat: "0.036399", + * networkFeeFiat: "0.008024478270232503211154803918368" + * }) => "0.999187951118199" + */ +export const calculateNetAmount = (params: { + totalFiat?: string; + bridgeFeeFiat?: string; + networkFeeFiat?: string; +}): string => { + const { totalFiat, bridgeFeeFiat, networkFeeFiat } = params; + + // totalFiat is required - return "0" if missing or invalid + if (!totalFiat) { + return '0'; + } + + const total = parseFloat(totalFiat); + if (isNaN(total)) { + return '0'; + } + + // Treat missing fees as 0, but validate they are numbers if provided + const bridgeFee = bridgeFeeFiat ? parseFloat(bridgeFeeFiat) : 0; + const networkFee = networkFeeFiat ? parseFloat(networkFeeFiat) : 0; + + // Return "0" if any provided fee is invalid + if (isNaN(bridgeFee) || isNaN(networkFee)) { + return '0'; + } + + // Calculate net amount: totalFiat - bridgeFee - networkFee + const netAmount = total - bridgeFee - networkFee; + + // Ensure we don't return negative amounts + return netAmount > 0 ? netAmount.toString() : '0'; +}; + /** * Formats a volume value with appropriate suffix based on magnitude * @param volume - Raw numeric volume value diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx index 2f1aec8141f..2c27bdded29 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx +++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import type { ReactTestInstance } from 'react-test-renderer'; import { screen, fireEvent, waitFor, act } from '@testing-library/react-native'; import { InteractionManager } from 'react-native'; import { @@ -416,7 +417,7 @@ jest.mock('../../../../../component-library/components/Icons/Icon', () => { }); function createMockMarket(overrides = {}) { - return { + const defaultMarket = { id: 'market-1', title: 'Will Bitcoin reach $100k by end of 2024?', image: 'https://example.com/bitcoin.png', @@ -430,8 +431,14 @@ function createMockMarket(overrides = {}) { tokens: [ { id: 'token-1', + title: 'Yes', price: 0.65, }, + { + id: 'token-2', + title: 'No', + price: 0.35, + }, ], volume: 1000000, }, @@ -441,15 +448,61 @@ function createMockMarket(overrides = {}) { groupItemTitle: 'No', tokens: [ { - id: 'token-2', + id: 'token-3', + title: 'No', price: 0.35, }, ], volume: 500000, }, ], + }; + + const mergedMarket = { + ...defaultMarket, ...overrides, }; + + const normalizedOutcomes = + mergedMarket.outcomes?.map((outcome, outcomeIndex) => { + const fallbackOutcomeTitle = + outcome.title ?? `Outcome ${outcomeIndex + 1}`; + + return { + ...outcome, + groupItemTitle: outcome.groupItemTitle ?? fallbackOutcomeTitle, + tokens: (outcome.tokens ?? []).map((token, tokenIndex) => { + let tokenTitle = token.title; + + if (!tokenTitle) { + if (outcomeIndex === 0 && tokenIndex === 0) { + tokenTitle = 'Yes'; + } else if (outcomeIndex === 0 && tokenIndex === 1) { + tokenTitle = 'No'; + } else { + tokenTitle = + outcome.groupItemTitle ?? + outcome.title ?? + `Token ${tokenIndex + 1}`; + + if (tokenIndex > 0 && tokenTitle === fallbackOutcomeTitle) { + tokenTitle = `${tokenTitle} ${tokenIndex + 1}`; + } + } + } + + return { + ...token, + title: tokenTitle, + }; + }), + }; + }) ?? []; + + return { + ...mergedMarket, + outcomes: normalizedOutcomes, + }; } type HookOverrideShape = Record; @@ -612,6 +665,37 @@ function setupPredictMarketDetailsTest( }; } +const collapseWhitespace = (text: string) => text.replace(/\s+/g, ''); + +const extractText = (node: React.ReactNode): string => { + if (typeof node === 'string' || typeof node === 'number') { + return String(node); + } + + if (Array.isArray(node)) { + return node.map(extractText).join(''); + } + + if (React.isValidElement(node)) { + return extractText(node.props.children); + } + + return ''; +}; + +const getActionButtonText = (button: ReactTestInstance) => + collapseWhitespace(extractText(button.props.children)); + +const getActionButtons = () => + screen + .getAllByTestId('button') + .filter((button) => getActionButtonText(button).includes('¢')); + +const findActionButtonByPrice = (price: number) => + getActionButtons().find( + (button) => getActionButtonText(button) === `•${price}¢`, + ); + describe('PredictMarketDetails', () => { afterEach(() => { jest.clearAllMocks(); @@ -848,7 +932,10 @@ describe('PredictMarketDetails', () => { { id: 'outcome-1', title: 'Yes', - tokens: [{ id: 'token-1', price: 0.65 }], + tokens: [ + { id: 'token-1', title: 'Yes', price: 0.65 }, + { id: 'token-2', title: 'No', price: 0.35 }, + ], volume: 1000000, }, ], @@ -856,12 +943,11 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(singleOutcomeMarket); - expect( - screen.getByText(/predict\.market_details\.yes\s*•\s*65¢/), - ).toBeOnTheScreen(); - expect( - screen.getByText(/predict\.market_details\.no\s*•\s*35¢/), - ).toBeOnTheScreen(); + const actionButtons = getActionButtons(); + const buttonLabels = actionButtons.map(getActionButtonText); + + expect(actionButtons).toHaveLength(2); + expect(buttonLabels).toEqual(expect.arrayContaining(['•65¢', '•35¢'])); }); it('calculates percentage correctly from market price', () => { @@ -871,7 +957,10 @@ describe('PredictMarketDetails', () => { { id: 'outcome-1', title: 'Yes', - tokens: [{ id: 'token-1', price: 0.75 }], + tokens: [ + { id: 'token-1', title: 'Yes', price: 0.75 }, + { id: 'token-2', title: 'No', price: 0.25 }, + ], volume: 1000000, }, ], @@ -879,12 +968,10 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(marketWithDifferentPrice); - expect( - screen.getByText(/predict\.market_details\.yes\s*•\s*75¢/), - ).toBeOnTheScreen(); - expect( - screen.getByText(/predict\.market_details\.no\s*•\s*25¢/), - ).toBeOnTheScreen(); + const actionButtons = getActionButtons(); + const buttonLabels = actionButtons.map(getActionButtonText); + + expect(buttonLabels).toEqual(expect.arrayContaining(['•75¢', '•25¢'])); }); }); @@ -918,7 +1005,10 @@ describe('PredictMarketDetails', () => { { id: 'outcome-1', title: 'Yes', - tokens: [{ id: 'token-1', price: 0.65 }], + tokens: [ + { id: 'token-1', title: 'Yes', price: 0.65 }, + { id: 'token-2', title: 'No', price: 0.35 }, + ], volume: 1000000, }, ], @@ -927,9 +1017,10 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(singleOutcomeMarket); // The component now shows the percentage in the action buttons instead of a separate text - expect( - screen.getByText(/predict\.market_details\.yes\s*•\s*65¢/), - ).toBeOnTheScreen(); + const actionButtons = getActionButtons(); + const buttonLabels = actionButtons.map(getActionButtonText); + + expect(buttonLabels).toContain('•65¢'); }); it('handles missing price data gracefully', () => { @@ -939,7 +1030,10 @@ describe('PredictMarketDetails', () => { { id: 'outcome-1', title: 'Yes', - tokens: [{ id: 'token-1', price: undefined }], + tokens: [ + { id: 'token-1', title: 'Yes', price: undefined }, + { id: 'token-2', title: 'No', price: 0.35 }, + ], volume: 1000000, }, ], @@ -948,9 +1042,10 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(marketWithoutPrice); // The component now shows 0% in the action buttons when price is undefined - expect( - screen.getByText(/predict\.market_details\.yes\s*•\s*0¢/), - ).toBeOnTheScreen(); + const actionButtons = getActionButtons(); + const buttonLabels = actionButtons.map(getActionButtonText); + + expect(buttonLabels).toEqual(expect.arrayContaining(['•0¢', '•100¢'])); }); }); @@ -1064,7 +1159,10 @@ describe('PredictMarketDetails', () => { { id: 'outcome-1', title: 'Yes', - tokens: [{ id: 'token-1', price: 0.65 }], + tokens: [ + { id: 'token-1', title: 'Yes', price: 0.65 }, + { id: 'token-2', title: 'No', price: 0.35 }, + ], volume: 1000000, }, ], @@ -1073,10 +1171,9 @@ describe('PredictMarketDetails', () => { const { mockNavigate } = setupPredictMarketDetailsTest(singleOutcomeMarket); - const yesButton = screen.getByText( - /predict\.market_details\.yes\s*•\s*65¢/, - ); - fireEvent.press(yesButton); + const yesButton = findActionButtonByPrice(65); + expect(yesButton).toBeDefined(); + fireEvent.press(yesButton as ReactTestInstance); expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.MODALS.ROOT, { screen: Routes.PREDICT.MODALS.BUY_PREVIEW, @@ -1097,8 +1194,8 @@ describe('PredictMarketDetails', () => { id: 'outcome-1', title: 'Yes', tokens: [ - { id: 'token-1', price: 0.65 }, - { id: 'token-2', price: 0.35 }, + { id: 'token-1', title: 'Yes', price: 0.65 }, + { id: 'token-2', title: 'No', price: 0.35 }, ], volume: 1000000, }, @@ -1108,10 +1205,9 @@ describe('PredictMarketDetails', () => { const { mockNavigate } = setupPredictMarketDetailsTest(singleOutcomeMarket); - const noButton = screen.getByText( - /predict\.market_details\.no\s*•\s*35¢/, - ); - fireEvent.press(noButton); + const noButton = findActionButtonByPrice(35); + expect(noButton).toBeDefined(); + fireEvent.press(noButton as ReactTestInstance); expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.MODALS.ROOT, { screen: Routes.PREDICT.MODALS.BUY_PREVIEW, @@ -1294,7 +1390,10 @@ describe('PredictMarketDetails', () => { { id: 'outcome-1', title: 'Yes', - tokens: [{ id: 'token-1', price: 0.65 }], + tokens: [ + { id: 'token-1', title: 'Yes', price: 0.65 }, + { id: 'token-2', title: 'No', price: 0.35 }, + ], volume: 1000000, }, ], @@ -1303,9 +1402,10 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(singleOutcomeMarket); // The component now shows the percentage in the action buttons instead of a separate text - expect( - screen.getByText(/predict\.market_details\.yes\s*•\s*65¢/), - ).toBeOnTheScreen(); + const actionButtons = getActionButtons(); + const buttonLabels = actionButtons.map(getActionButtonText); + + expect(buttonLabels).toContain('•65¢'); }); it('renders action buttons only for single outcome markets', () => { @@ -1315,7 +1415,10 @@ describe('PredictMarketDetails', () => { { id: 'outcome-1', title: 'Yes', - tokens: [{ id: 'token-1', price: 0.65 }], + tokens: [ + { id: 'token-1', title: 'Yes', price: 0.65 }, + { id: 'token-2', title: 'No', price: 0.35 }, + ], volume: 1000000, }, ], @@ -1323,12 +1426,10 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(singleOutcomeMarket); - expect( - screen.getByText(/predict\.market_details\.yes\s*•\s*65¢/), - ).toBeOnTheScreen(); - expect( - screen.getByText(/predict\.market_details\.no\s*•\s*35¢/), - ).toBeOnTheScreen(); + const actionButtons = getActionButtons(); + const buttonLabels = actionButtons.map(getActionButtonText); + + expect(buttonLabels).toEqual(expect.arrayContaining(['•65¢', '•35¢'])); }); }); @@ -1482,7 +1583,10 @@ describe('PredictMarketDetails', () => { { id: 'outcome-1', title: 'Yes', - tokens: [{ id: 'token-1', price: undefined }], + tokens: [ + { id: 'token-1', title: 'Yes', price: undefined }, + { id: 'token-2', title: 'No', price: 0.35 }, + ], volume: 1000000, }, ], @@ -1491,9 +1595,11 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(marketWithUndefinedPrice); // The component now shows 0% in the action buttons when price is undefined - expect( - screen.getByText(/predict\.market_details\.yes\s*•\s*0¢/), - ).toBeOnTheScreen(); + const yesButton = findActionButtonByPrice(0); + const noButton = findActionButtonByPrice(100); + + expect(yesButton).toBeDefined(); + expect(noButton).toBeDefined(); }); }); @@ -1900,7 +2006,10 @@ describe('PredictMarketDetails', () => { { id: 'outcome-1', title: 'Yes', - tokens: [{ id: 'token-1', price: 0.65 }], + tokens: [ + { id: 'token-1', title: 'Yes', price: 0.65 }, + { id: 'token-2', title: 'No', price: 0.35 }, + ], volume: 1000000, }, ], @@ -1909,10 +2018,9 @@ describe('PredictMarketDetails', () => { const { mockNavigate } = setupPredictMarketDetailsTest(singleOutcomeMarket); - const yesButton = screen.getByText( - /predict\.market_details\.yes\s*•\s*65¢/, - ); - fireEvent.press(yesButton); + const yesButton = findActionButtonByPrice(65); + expect(yesButton).toBeDefined(); + fireEvent.press(yesButton as ReactTestInstance); expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.MODALS.ROOT, { screen: Routes.PREDICT.MODALS.ADD_FUNDS_SHEET, @@ -1934,8 +2042,8 @@ describe('PredictMarketDetails', () => { id: 'outcome-1', title: 'Yes', tokens: [ - { id: 'token-1', price: 0.65 }, - { id: 'token-2', price: 0.35 }, + { id: 'token-1', title: 'Yes', price: 0.65 }, + { id: 'token-2', title: 'No', price: 0.35 }, ], volume: 1000000, }, @@ -1945,10 +2053,9 @@ describe('PredictMarketDetails', () => { const { mockNavigate } = setupPredictMarketDetailsTest(singleOutcomeMarket); - const noButton = screen.getByText( - /predict\.market_details\.no\s*•\s*35¢/, - ); - fireEvent.press(noButton); + const noButton = findActionButtonByPrice(35); + expect(noButton).toBeDefined(); + fireEvent.press(noButton as ReactTestInstance); expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.MODALS.ROOT, { screen: Routes.PREDICT.MODALS.ADD_FUNDS_SHEET, @@ -1962,7 +2069,10 @@ describe('PredictMarketDetails', () => { { id: 'outcome-1', title: 'Yes', - tokens: [{ id: 'token-1', price: 0.65 }], + tokens: [ + { id: 'token-1', title: 'Yes', price: 0.65 }, + { id: 'token-2', title: 'No', price: 0.35 }, + ], volume: 1000000, }, ], @@ -1974,10 +2084,9 @@ describe('PredictMarketDetails', () => { { eligibility: { isEligible: false } }, ); - const yesButton = screen.getByText( - /predict\.market_details\.yes\s*•\s*65¢/, - ); - fireEvent.press(yesButton); + const yesButton = findActionButtonByPrice(65); + expect(yesButton).toBeDefined(); + fireEvent.press(yesButton as ReactTestInstance); expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.MODALS.ROOT, { screen: Routes.PREDICT.MODALS.UNAVAILABLE, @@ -1992,8 +2101,8 @@ describe('PredictMarketDetails', () => { id: 'outcome-1', title: 'Yes', tokens: [ - { id: 'token-1', price: 0.65 }, - { id: 'token-2', price: 0.35 }, + { id: 'token-1', title: 'Yes', price: 0.65 }, + { id: 'token-2', title: 'No', price: 0.35 }, ], volume: 1000000, }, @@ -2006,10 +2115,9 @@ describe('PredictMarketDetails', () => { { eligibility: { isEligible: false } }, ); - const noButton = screen.getByText( - /predict\.market_details\.no\s*•\s*35¢/, - ); - fireEvent.press(noButton); + const noButton = findActionButtonByPrice(35); + expect(noButton).toBeDefined(); + fireEvent.press(noButton as ReactTestInstance); expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.MODALS.ROOT, { screen: Routes.PREDICT.MODALS.UNAVAILABLE, @@ -2030,7 +2138,10 @@ describe('PredictMarketDetails', () => { { id: 'outcome-1', title: 'Yes', - tokens: [{ id: 'token-1', price: 0.65 }], + tokens: [ + { id: 'token-1', title: 'Yes', price: 0.65 }, + { id: 'token-2', title: 'No', price: 0.35 }, + ], volume: 1000000, }, ], @@ -2042,10 +2153,9 @@ describe('PredictMarketDetails', () => { { eligibility: { isEligible: false } }, ); - const yesButton = screen.getByText( - /predict\.market_details\.yes\s*•\s*65¢/, - ); - fireEvent.press(yesButton); + const yesButton = findActionButtonByPrice(65); + expect(yesButton).toBeDefined(); + fireEvent.press(yesButton as ReactTestInstance); expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.MODALS.ROOT, { screen: Routes.PREDICT.MODALS.UNAVAILABLE, @@ -2073,8 +2183,8 @@ describe('PredictMarketDetails', () => { id: 'outcome-1', title: 'Yes', tokens: [ - { id: 'token-1', price: 0.65 }, - { id: 'token-2', price: 0.35 }, + { id: 'token-1', title: 'Yes', price: 0.65 }, + { id: 'token-2', title: 'No', price: 0.35 }, ], volume: 1000000, }, @@ -2087,10 +2197,9 @@ describe('PredictMarketDetails', () => { { eligibility: { isEligible: false } }, ); - const noButton = screen.getByText( - /predict\.market_details\.no\s*•\s*35¢/, - ); - fireEvent.press(noButton); + const noButton = findActionButtonByPrice(35); + expect(noButton).toBeDefined(); + fireEvent.press(noButton as ReactTestInstance); expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.MODALS.ROOT, { screen: Routes.PREDICT.MODALS.UNAVAILABLE, @@ -2277,9 +2386,7 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(marketWithPartialResolution); - expect( - screen.queryByTestId('predict-details-chart'), - ).not.toBeOnTheScreen(); + expect(screen.getByTestId('predict-details-chart')).toBeOnTheScreen(); }); it('displays resolved outcomes count badge', () => { diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx index b0014aafa87..dfab14ded83 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx +++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx @@ -904,8 +904,7 @@ const PredictMarketDetails: React.FC = () => { style={tw.style('flex-1 bg-success-muted')} label={ - {strings('predict.market_details.yes')} •{' '} - {getYesPercentage()}¢ + {firstOpenOutcome?.tokens[0].title} • {getYesPercentage()}¢ } onPress={() => @@ -922,7 +921,7 @@ const PredictMarketDetails: React.FC = () => { style={tw.style('flex-1 bg-error-muted')} label={ - {strings('predict.market_details.no')} •{' '} + {firstOpenOutcome?.tokens[1].title} •{' '} {100 - getYesPercentage()}¢ } @@ -1137,16 +1136,14 @@ const PredictMarketDetails: React.FC = () => { {/* Header content - scrollable */} {renderMarketStatus()} - {!multipleOpenOutcomesPartiallyResolved && ( - - )} + {/* Show content skeleton while initial market data is fetching */} diff --git a/app/components/UI/Ramp/components/EligibilityFailedModal/EligibilityFailedModal.styles.ts b/app/components/UI/Ramp/components/EligibilityFailedModal/EligibilityFailedModal.styles.ts new file mode 100644 index 00000000000..2dd972f0c96 --- /dev/null +++ b/app/components/UI/Ramp/components/EligibilityFailedModal/EligibilityFailedModal.styles.ts @@ -0,0 +1,16 @@ +import { StyleSheet } from 'react-native'; + +const styleSheet = () => + StyleSheet.create({ + content: { + paddingHorizontal: 24, + paddingBottom: 24, + }, + footer: { + gap: 16, + paddingHorizontal: 24, + paddingBottom: 24, + }, + }); + +export default styleSheet; diff --git a/app/components/UI/Ramp/components/EligibilityFailedModal/EligibilityFailedModal.test.tsx b/app/components/UI/Ramp/components/EligibilityFailedModal/EligibilityFailedModal.test.tsx new file mode 100644 index 00000000000..dcaa98af485 --- /dev/null +++ b/app/components/UI/Ramp/components/EligibilityFailedModal/EligibilityFailedModal.test.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { renderScreen } from '../../../../../util/test/renderWithProvider'; +import EligibilityFailedModal from './EligibilityFailedModal'; +import Routes from '../../../../../constants/navigation/Routes'; +import initialRootState from '../../../../../util/test/initial-root-state'; +import { fireEvent } from '@testing-library/react-native'; +import { Linking } from 'react-native'; + +const mockOnCloseBottomSheet = jest.fn(); + +jest.mock('react-native/Libraries/Linking/Linking', () => ({ + openURL: jest.fn(), + addEventListener: jest.fn(() => ({ remove: jest.fn() })), + removeEventListener: jest.fn(), + canOpenURL: jest.fn().mockResolvedValue(true), +})); + +jest.mock( + '../../../../../component-library/components/BottomSheets/BottomSheet', + () => { + const ReactActual = jest.requireActual('react'); + return ReactActual.forwardRef( + ( + { + children, + }: { + children: React.ReactNode; + }, + ref: React.Ref<{ onCloseBottomSheet: () => void }>, + ) => { + ReactActual.useImperativeHandle(ref, () => ({ + onCloseBottomSheet: mockOnCloseBottomSheet, + })); + return <>{children}; + }, + ); + }, +); + +function render(component: React.ComponentType) { + return renderScreen( + component, + { + name: Routes.SHEET.ELIGIBILITY_FAILED_MODAL, + }, + { + state: initialRootState, + }, + ); +} + +describe('EligibilityFailedModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the modal with the correct title and description', () => { + const { toJSON } = render(EligibilityFailedModal); + expect(toJSON()).toMatchSnapshot(); + }); + it('navigates to contact support when the contact support button is pressed', () => { + const { getByText } = render(EligibilityFailedModal); + const contactSupportButton = getByText('Contact Support'); + + fireEvent.press(contactSupportButton); + + expect(Linking.openURL).toHaveBeenCalledWith('https://support.metamask.io'); + }); + + it('closes the modal when the close button is pressed', () => { + const { getByTestId } = render(EligibilityFailedModal); + const closeButton = getByTestId('bottomsheetheader-close-button'); + + fireEvent.press(closeButton); + + expect(mockOnCloseBottomSheet).toHaveBeenCalledTimes(1); + }); + + it('closes the modal when the got it button is pressed', () => { + const { getByText } = render(EligibilityFailedModal); + const gotItButton = getByText('Got It'); + + fireEvent.press(gotItButton); + + expect(mockOnCloseBottomSheet).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/components/UI/Ramp/components/EligibilityFailedModal/EligibilityFailedModal.tsx b/app/components/UI/Ramp/components/EligibilityFailedModal/EligibilityFailedModal.tsx new file mode 100644 index 00000000000..b2d47bcf507 --- /dev/null +++ b/app/components/UI/Ramp/components/EligibilityFailedModal/EligibilityFailedModal.tsx @@ -0,0 +1,91 @@ +import React, { useCallback, useRef } from 'react'; +import { View, Linking } from 'react-native'; +import Text, { + TextVariant, + TextColor, +} from '../../../../../component-library/components/Texts/Text'; +import BottomSheet, { + BottomSheetRef, +} from '../../../../../component-library/components/BottomSheets/BottomSheet'; +import BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader'; +import Button, { + ButtonSize, + ButtonVariants, + ButtonWidthTypes, +} from '../../../../../component-library/components/Buttons/Button'; + +import styleSheet from './EligibilityFailedModal.styles'; +import { useStyles } from '../../../../hooks/useStyles'; +import { createNavigationDetails } from '../../../../../util/navigation/navUtils'; +import Routes from '../../../../../constants/navigation/Routes'; +import { strings } from '../../../../../../locales/i18n'; + +const SUPPORT_URL = 'https://support.metamask.io'; + +export const createEligibilityFailedModalNavigationDetails = + createNavigationDetails( + Routes.MODAL.ROOT_MODAL_FLOW, + Routes.SHEET.ELIGIBILITY_FAILED_MODAL, + ); + +function EligibilityFailedModal() { + const sheetRef = useRef(null); + const { styles } = useStyles(styleSheet, {}); + + const navigateToContactSupport = useCallback(() => { + Linking.openURL(SUPPORT_URL); + }, []); + + const handleClose = useCallback(() => { + sheetRef.current?.onCloseBottomSheet(); + }, []); + + return ( + + + + {strings('fiat_on_ramp_aggregator.eligibility_failed_modal.title')} + + + + + + {strings( + 'fiat_on_ramp_aggregator.eligibility_failed_modal.description', + )} + + + + +