From b59a206270c1cd4185ee88655dce694a9be6b1d8 Mon Sep 17 00:00:00 2001 From: michaellwin Date: Wed, 31 Dec 2025 08:56:36 +0100 Subject: [PATCH 1/2] feat: add outcome rules display and navigation to Polymarket in PredictMarketDetails - Introduced a new component, `PredictOutcomeRules`, to display outcome rules based on market descriptions. - Updated `PredictMarketOutcome` to conditionally render `PredictOutcomeRules` when an outcome description is available. - Enhanced `PredictMarketDetails` tests to verify the display of rules and navigation to the Polymarket event page when the "view rules" button is pressed. - Added localization strings for rules and disclaimers in the English language JSON file. --- .../PredictMarketOutcome.tsx | 4 + .../PredictOutcomeRules.test.tsx | 72 ++ .../PredictOutcomeRules.tsx | 78 +++ .../components/PredictOutcomeRules/index.ts | 2 + .../PredictMarketDetails.test.tsx | 135 ++++ .../PredictMarketDetails.tsx | 257 ++++--- .../predict-market-rules-implementation.md | 639 ++++++++++++++++++ locales/languages/en.json | 8 +- 8 files changed, 1116 insertions(+), 79 deletions(-) create mode 100644 app/components/UI/Predict/components/PredictOutcomeRules/PredictOutcomeRules.test.tsx create mode 100644 app/components/UI/Predict/components/PredictOutcomeRules/PredictOutcomeRules.tsx create mode 100644 app/components/UI/Predict/components/PredictOutcomeRules/index.ts create mode 100644 docs/predict/predict-market-rules-implementation.md diff --git a/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.tsx b/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.tsx index 781bb213e6b5..716128a03416 100644 --- a/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.tsx +++ b/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.tsx @@ -39,6 +39,7 @@ import { } from '../../utils/format'; import styleSheet from './PredictMarketOutcome.styles'; import { usePredictActionGuard } from '../../hooks/usePredictActionGuard'; +import PredictOutcomeRules from '../PredictOutcomeRules'; interface PredictMarketOutcomeProps { market: PredictMarket; outcome: PredictOutcomeType; @@ -224,6 +225,9 @@ const PredictMarketOutcome: React.FC = ({ /> )} + {outcome.description && ( + + )} ); }; diff --git a/app/components/UI/Predict/components/PredictOutcomeRules/PredictOutcomeRules.test.tsx b/app/components/UI/Predict/components/PredictOutcomeRules/PredictOutcomeRules.test.tsx new file mode 100644 index 000000000000..46931b5d9492 --- /dev/null +++ b/app/components/UI/Predict/components/PredictOutcomeRules/PredictOutcomeRules.test.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { fireEvent, screen } from '@testing-library/react-native'; +import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import { backgroundState } from '../../../../../util/test/initial-root-state'; +import PredictOutcomeRules from '.'; + +const initialState = { + engine: { backgroundState }, +}; + +describe('PredictOutcomeRules', () => { + const mockDescription = 'This market resolves to Yes if BTC hits $100k.'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders nothing when description is empty', () => { + renderWithProvider(, { + state: initialState, + }); + + expect(screen.queryByText('Show rules')).toBeNull(); + }); + + it('renders collapsed state initially', () => { + renderWithProvider( + , + { state: initialState }, + ); + + expect(screen.getByText('Show rules')).toBeOnTheScreen(); + expect(screen.queryByText(mockDescription)).toBeNull(); + }); + + it('expands to show rules when pressed', () => { + renderWithProvider( + , + { state: initialState }, + ); + + fireEvent.press(screen.getByText('Show rules')); + + expect(screen.getByText('Hide rules')).toBeOnTheScreen(); + expect(screen.getByText(mockDescription)).toBeOnTheScreen(); + }); + + it('collapses when pressed again', () => { + renderWithProvider( + , + { state: initialState }, + ); + + fireEvent.press(screen.getByText('Show rules')); + fireEvent.press(screen.getByText('Hide rules')); + + expect(screen.getByText('Show rules')).toBeOnTheScreen(); + expect(screen.queryByText(mockDescription)).toBeNull(); + }); + + it('displays title when provided', () => { + renderWithProvider( + , + { state: initialState }, + ); + + fireEvent.press(screen.getByText('Show rules')); + + expect(screen.getByText('BTC $100k')).toBeOnTheScreen(); + }); +}); + diff --git a/app/components/UI/Predict/components/PredictOutcomeRules/PredictOutcomeRules.tsx b/app/components/UI/Predict/components/PredictOutcomeRules/PredictOutcomeRules.tsx new file mode 100644 index 000000000000..7a9bc1a84ef5 --- /dev/null +++ b/app/components/UI/Predict/components/PredictOutcomeRules/PredictOutcomeRules.tsx @@ -0,0 +1,78 @@ +import React, { useState } from 'react'; +import { Pressable } from 'react-native'; +import { + Box, + BoxFlexDirection, + BoxAlignItems, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import Icon, { + IconName, + IconSize, +} from '../../../../../component-library/components/Icons/Icon'; +import { strings } from '../../../../../../locales/i18n'; +import { useTheme } from '../../../../../util/theme'; + +interface PredictOutcomeRulesProps { + description: string; + title?: string; +} + +const PredictOutcomeRules: React.FC = ({ + description, + title, +}) => { + const [isExpanded, setIsExpanded] = useState(false); + const { colors } = useTheme(); + + if (!description) return null; + + return ( + + setIsExpanded(!isExpanded)}> + + + + {isExpanded + ? strings('predict.market_details.hide_rules') + : strings('predict.market_details.show_rules')} + + + + + {isExpanded && ( + + {title && ( + + {title} + + )} + + {description} + + + )} + + ); +}; + +export default PredictOutcomeRules; + diff --git a/app/components/UI/Predict/components/PredictOutcomeRules/index.ts b/app/components/UI/Predict/components/PredictOutcomeRules/index.ts new file mode 100644 index 000000000000..4e2db1c5db65 --- /dev/null +++ b/app/components/UI/Predict/components/PredictOutcomeRules/index.ts @@ -0,0 +1,2 @@ +export { default } from './PredictOutcomeRules'; + diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx index e7769c3ed6aa..33f356323419 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx +++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx @@ -3444,4 +3444,139 @@ describe('PredictMarketDetails', () => { }); }); }); + + describe('About Tab - Rules Display', () => { + it('displays rules disclaimer with Polymarket link', () => { + setupPredictMarketDetailsTest(); + + const aboutTab = screen.getByTestId( + 'predict-market-details-tab-bar-tab-1', + ); + fireEvent.press(aboutTab); + + expect( + screen.getByText('predict.market_details.rules'), + ).toBeOnTheScreen(); + expect( + screen.getByText('predict.market_details.rules_disclaimer'), + ).toBeOnTheScreen(); + expect( + screen.getByText('predict.market_details.view_rules'), + ).toBeOnTheScreen(); + }); + + it('navigates to Polymarket event page when view rules is pressed', () => { + mockRunAfterInteractions.mockImplementation(runAfterInteractionsMockImpl); + // Need to provide a slug for handleViewMarketOnPolymarket to work + const marketWithSlug = createMockMarket({ + slug: 'bitcoin-100k-2024', + }); + const { mockNavigate } = setupPredictMarketDetailsTest(marketWithSlug); + + const aboutTab = screen.getByTestId( + 'predict-market-details-tab-bar-tab-1', + ); + fireEvent.press(aboutTab); + + const viewRulesButton = screen.getByText( + 'predict.market_details.view_rules', + ); + act(() => { + fireEvent.press(viewRulesButton); + }); + + const callback = + runAfterInteractionsCallbacks[ + runAfterInteractionsCallbacks.length - 1 + ]; + act(() => { + callback?.(); + }); + + expect(mockNavigate).toHaveBeenCalledWith('Webview', { + screen: 'SimpleWebview', + params: { + url: expect.stringContaining('polymarket.com/event/'), + title: expect.any(String), + }, + }); + }); + + it('displays outcome rules for multi-outcome markets', () => { + const multiOutcomeMarket = createMockMarket({ + description: 'Event level description', + outcomes: [ + { + id: 'outcome-1', + title: 'BTC $100k', + groupItemTitle: '↑ $100,000', + description: 'Resolves Yes if BTC reaches $100k on Binance.', + status: 'open', + tokens: [ + { id: 'token-1', title: 'Yes', price: 0.65 }, + { id: 'token-2', title: 'No', price: 0.35 }, + ], + volume: 1000000, + }, + { + id: 'outcome-2', + title: 'BTC $120k', + groupItemTitle: '↑ $120,000', + description: 'Resolves Yes if BTC reaches $120k on Binance.', + status: 'open', + tokens: [ + { id: 'token-3', title: 'Yes', price: 0.25 }, + { id: 'token-4', title: 'No', price: 0.75 }, + ], + volume: 500000, + }, + ], + }); + + setupPredictMarketDetailsTest(multiOutcomeMarket); + + const aboutTab = screen.getByTestId( + 'predict-market-details-tab-bar-tab-1', + ); + fireEvent.press(aboutTab); + + expect( + screen.getByText('predict.market_details.outcome_rules'), + ).toBeOnTheScreen(); + expect(screen.getByText('↑ $100,000')).toBeOnTheScreen(); + expect( + screen.getByText('Resolves Yes if BTC reaches $100k on Binance.'), + ).toBeOnTheScreen(); + }); + + it('hides outcome rules section for single-outcome markets', () => { + const singleOutcomeMarket = createMockMarket({ + outcomes: [ + { + id: 'outcome-1', + title: 'Will it happen?', + description: 'Market rules here.', + status: 'open', + tokens: [ + { id: 'token-1', title: 'Yes', price: 0.5 }, + { id: 'token-2', title: 'No', price: 0.5 }, + ], + volume: 1000000, + }, + ], + }); + + setupPredictMarketDetailsTest(singleOutcomeMarket); + + // Single-outcome markets only have About tab at index 0 + const aboutTab = screen.getByTestId( + 'predict-market-details-tab-bar-tab-0', + ); + fireEvent.press(aboutTab); + + expect( + screen.queryByText('predict.market_details.outcome_rules'), + ).toBeNull(); + }); + }); }); diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx index 83f2b1e0419a..221f116273ad 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx +++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx @@ -616,6 +616,19 @@ const PredictMarketDetails: React.FC = () => { }); }, [navigation]); + const handleViewMarketOnPolymarket = useCallback(() => { + if (!market?.slug) return; + InteractionManager.runAfterInteractions(() => { + navigation.navigate('Webview', { + screen: 'SimpleWebview', + params: { + url: `https://polymarket.com/event/${market.slug}`, + title: market.title, + }, + }); + }); + }, [navigation, market?.slug, market?.title]); + type TabKey = 'positions' | 'outcomes' | 'about'; const trackMarketDetailsOpened = useCallback( @@ -904,127 +917,215 @@ const PredictMarketDetails: React.FC = () => { ); }; - const renderAboutSection = () => ( - - - + const renderAboutSection = () => { + const hasMultipleOutcomes = (market?.outcomes?.length ?? 0) > 1; + + return ( + + {/* Market Stats */} + - + + + + {strings('predict.market_details.volume')} + + - {strings('predict.market_details.volume')} + ${formatVolume(market?.outcomes[0].volume || 0)} - - ${formatVolume(market?.outcomes[0].volume || 0)} - - - - + + + + {strings('predict.market_details.end_date')} + + - {strings('predict.market_details.end_date')} + {market?.endDate + ? new Date(market?.endDate).toLocaleDateString() + : 'N/A'} - - {market?.endDate - ? new Date(market?.endDate).toLocaleDateString() - : 'N/A'} - + + + + {strings('predict.market_details.resolution_details')} + + + + + + Polymarket + + + + + - + + + + {/* Event Description */} + + {market?.description} + + + {/* Outcome Rules (multi-outcome markets only) */} + {hasMultipleOutcomes && ( + <> + + + + {strings('predict.market_details.outcome_rules')} + + {market?.outcomes + ?.filter((o) => o.description) + .map((outcome) => ( + + + {outcome.groupItemTitle || outcome.title} + + + {outcome.description} + + + ))} + + + )} + + {/* Disclaimer with Link */} + - {strings('predict.market_details.resolution_details')} + {strings('predict.market_details.rules')} - - + + {strings('predict.market_details.rules_disclaimer')} + + + - Polymarket + {strings('predict.market_details.view_rules')} - - - + + + - - - {market?.description} - - - ); + ); + }; // see if there are any positions with positive percentPnl const hasPositivePnl = claimablePositions.some( diff --git a/docs/predict/predict-market-rules-implementation.md b/docs/predict/predict-market-rules-implementation.md new file mode 100644 index 000000000000..5818e8115f58 --- /dev/null +++ b/docs/predict/predict-market-rules-implementation.md @@ -0,0 +1,639 @@ +# Implementation Plan: Display Market Rules + +## Problem Summary + +MetaMask shows only **event-level description** (vague) instead of **market-level rules** (detailed). The data is already fetched into `PredictOutcome.description` but never displayed. + +### Example + +| Market | Event Description (shown) | Outcome Rules (not shown) | +|--------|---------------------------|---------------------------| +| "What price will Bitcoin hit in 2025?" | "This is a market group over whit prices Bitcoin will hit in 2025." | "This market will immediately resolve to 'Yes' if any Binance 1 minute candle for Bitcoin (BTCUSDT)..." | + +--- + +## Solution Overview + +| Option | Description | +|--------|-------------| +| **1** | Show expandable rules per outcome in `PredictMarketOutcome` component | +| **2** | Display outcome-specific rules in About tab for multi-outcome markets | +| **3** | Add prominent disclaimer with link to Polymarket | + +--- + +## Changes Summary + +| File | Change | +|------|--------| +| `locales/languages/en.json` | Add new i18n strings | +| `PredictMarketDetails.tsx` | Options 2 & 3: Update About section | +| `PredictOutcomeRules.tsx` | **NEW**: Collapsible rules component | +| `PredictOutcomeRules.test.tsx` | **NEW**: Tests for rules component | +| `PredictMarketOutcome.tsx` | Option 1: Add rules expansion | +| `PredictMarketDetails.test.tsx` | Add tests for About section changes | + +--- + +## 1. Add i18n Strings + +**File**: `locales/languages/en.json` + +Add to the `predict.market_details` section: + +```json +"rules": "Rules", +"rules_disclaimer": "For complete market rules and resolution details, visit Polymarket.", +"view_rules": "View full rules", +"outcome_rules": "Outcome Rules", +"show_rules": "Show rules", +"hide_rules": "Hide rules" +``` + +--- + +## 2. Create Collapsible Rules Component (Option 1) + +### New File: `app/components/UI/Predict/components/PredictOutcomeRules/PredictOutcomeRules.tsx` + +```tsx +import React, { useState } from 'react'; +import { Pressable } from 'react-native'; +import { + Box, + BoxFlexDirection, + BoxAlignItems, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import Icon, { + IconName, + IconSize, +} from '../../../../../component-library/components/Icons/Icon'; +import { strings } from '../../../../../../locales/i18n'; +import { useTheme } from '../../../../../util/theme'; + +interface PredictOutcomeRulesProps { + description: string; + title?: string; +} + +const PredictOutcomeRules: React.FC = ({ + description, + title, +}) => { + const [isExpanded, setIsExpanded] = useState(false); + const { colors } = useTheme(); + + if (!description) return null; + + return ( + + setIsExpanded(!isExpanded)}> + + + + {isExpanded + ? strings('predict.market_details.hide_rules') + : strings('predict.market_details.show_rules')} + + + + + {isExpanded && ( + + {title && ( + + {title} + + )} + + {description} + + + )} + + ); +}; + +export default PredictOutcomeRules; +``` + +### New File: `app/components/UI/Predict/components/PredictOutcomeRules/index.ts` + +```ts +export { default } from './PredictOutcomeRules'; +``` + +--- + +## 3. Update PredictMarketOutcome (Option 1) + +**File**: `app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.tsx` + +Add import: + +```tsx +import PredictOutcomeRules from '../PredictOutcomeRules'; +``` + +Add rules display below the button container (before closing ``): + +```tsx + {!isClosed && ( + + {/* ... existing buttons ... */} + + )} + {outcome.description && ( + + )} + + ); +``` + +--- + +## 4. Update About Section (Options 2 & 3) + +**File**: `app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx` + +### 4.1 Add Polymarket Market Link Handler + +Add after `handlePolymarketResolution` (around line 617): + +```tsx +const handleViewMarketOnPolymarket = useCallback(() => { + if (!market?.slug) return; + InteractionManager.runAfterInteractions(() => { + navigation.navigate('Webview', { + screen: 'SimpleWebview', + params: { + url: `https://polymarket.com/event/${market.slug}`, + title: market.title, + }, + }); + }); +}, [navigation, market?.slug, market?.title]); +``` + +### 4.2 Replace renderAboutSection + +Replace the existing `renderAboutSection` function with: + +```tsx +const renderAboutSection = () => { + const hasMultipleOutcomes = (market?.outcomes?.length ?? 0) > 1; + + return ( + + {/* Market Stats */} + + + + + + {strings('predict.market_details.volume')} + + + + ${formatVolume(market?.outcomes[0].volume || 0)} + + + + + + + {strings('predict.market_details.end_date')} + + + + {market?.endDate + ? new Date(market?.endDate).toLocaleDateString() + : 'N/A'} + + + + + + + {strings('predict.market_details.resolution_details')} + + + + + + Polymarket + + + + + + + + + + {/* Event Description */} + + {market?.description} + + + {/* Option 2: Outcome Rules (multi-outcome markets only) */} + {hasMultipleOutcomes && ( + <> + + + + {strings('predict.market_details.outcome_rules')} + + {market?.outcomes + ?.filter((o) => o.description) + .map((outcome) => ( + + + {outcome.groupItemTitle || outcome.title} + + + {outcome.description} + + + ))} + + + )} + + {/* Option 3: Disclaimer with Link */} + + + + + {strings('predict.market_details.rules')} + + + + {strings('predict.market_details.rules_disclaimer')} + + + + + {strings('predict.market_details.view_rules')} + + + + + + + ); +}; +``` + +--- + +## 5. Tests + +### 5.1 PredictOutcomeRules Tests + +**New File**: `app/components/UI/Predict/components/PredictOutcomeRules/PredictOutcomeRules.test.tsx` + +```tsx +import React from 'react'; +import { fireEvent, screen } from '@testing-library/react-native'; +import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import { backgroundState } from '../../../../../util/test/initial-root-state'; +import PredictOutcomeRules from '.'; + +const initialState = { + engine: { backgroundState }, +}; + +describe('PredictOutcomeRules', () => { + const mockDescription = 'This market resolves to Yes if BTC hits $100k.'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders nothing when description is empty', () => { + renderWithProvider(, { + state: initialState, + }); + + expect(screen.queryByText('predict.market_details.show_rules')).toBeNull(); + }); + + it('renders collapsed state initially', () => { + renderWithProvider( + , + { state: initialState }, + ); + + expect( + screen.getByText('predict.market_details.show_rules'), + ).toBeOnTheScreen(); + expect(screen.queryByText(mockDescription)).toBeNull(); + }); + + it('expands to show rules when pressed', () => { + renderWithProvider( + , + { state: initialState }, + ); + + fireEvent.press(screen.getByText('predict.market_details.show_rules')); + + expect( + screen.getByText('predict.market_details.hide_rules'), + ).toBeOnTheScreen(); + expect(screen.getByText(mockDescription)).toBeOnTheScreen(); + }); + + it('collapses when pressed again', () => { + renderWithProvider( + , + { state: initialState }, + ); + + fireEvent.press(screen.getByText('predict.market_details.show_rules')); + fireEvent.press(screen.getByText('predict.market_details.hide_rules')); + + expect( + screen.getByText('predict.market_details.show_rules'), + ).toBeOnTheScreen(); + expect(screen.queryByText(mockDescription)).toBeNull(); + }); + + it('displays title when provided', () => { + renderWithProvider( + , + { state: initialState }, + ); + + fireEvent.press(screen.getByText('predict.market_details.show_rules')); + + expect(screen.getByText('BTC $100k')).toBeOnTheScreen(); + }); +}); +``` + +### 5.2 PredictMarketDetails Tests + +**File**: `app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx` + +Add new describe block: + +```tsx +describe('About Tab - Rules Display', () => { + it('displays rules disclaimer with Polymarket link', () => { + setupPredictMarketDetailsTest(); + + const aboutTab = screen.getByTestId('predict-market-details-tab-bar-tab-1'); + fireEvent.press(aboutTab); + + expect(screen.getByText('predict.market_details.rules')).toBeOnTheScreen(); + expect( + screen.getByText('predict.market_details.rules_disclaimer'), + ).toBeOnTheScreen(); + expect( + screen.getByText('predict.market_details.view_rules'), + ).toBeOnTheScreen(); + }); + + it('navigates to Polymarket event page when view rules is pressed', () => { + const { mockNavigate } = setupPredictMarketDetailsTest(); + + const aboutTab = screen.getByTestId('predict-market-details-tab-bar-tab-1'); + fireEvent.press(aboutTab); + + const viewRulesButton = screen.getByText('predict.market_details.view_rules'); + act(() => { + fireEvent.press(viewRulesButton); + }); + + const callback = + runAfterInteractionsCallbacks[runAfterInteractionsCallbacks.length - 1]; + act(() => { + callback?.(); + }); + + expect(mockNavigate).toHaveBeenCalledWith('Webview', { + screen: 'SimpleWebview', + params: { + url: expect.stringContaining('polymarket.com/event/'), + title: expect.any(String), + }, + }); + }); + + it('displays outcome rules for multi-outcome markets', () => { + const multiOutcomeMarket = createMockMarket({ + description: 'Event level description', + outcomes: [ + { + id: 'outcome-1', + title: 'BTC $100k', + groupItemTitle: '↑ $100,000', + description: 'Resolves Yes if BTC reaches $100k on Binance.', + status: 'open', + tokens: [ + { id: 'token-1', title: 'Yes', price: 0.65 }, + { id: 'token-2', title: 'No', price: 0.35 }, + ], + volume: 1000000, + }, + { + id: 'outcome-2', + title: 'BTC $120k', + groupItemTitle: '↑ $120,000', + description: 'Resolves Yes if BTC reaches $120k on Binance.', + status: 'open', + tokens: [ + { id: 'token-3', title: 'Yes', price: 0.25 }, + { id: 'token-4', title: 'No', price: 0.75 }, + ], + volume: 500000, + }, + ], + }); + + setupPredictMarketDetailsTest(multiOutcomeMarket); + + const aboutTab = screen.getByTestId('predict-market-details-tab-bar-tab-1'); + fireEvent.press(aboutTab); + + expect( + screen.getByText('predict.market_details.outcome_rules'), + ).toBeOnTheScreen(); + expect(screen.getByText('↑ $100,000')).toBeOnTheScreen(); + expect( + screen.getByText('Resolves Yes if BTC reaches $100k on Binance.'), + ).toBeOnTheScreen(); + }); + + it('hides outcome rules section for single-outcome markets', () => { + const singleOutcomeMarket = createMockMarket({ + outcomes: [ + { + id: 'outcome-1', + title: 'Will it happen?', + description: 'Market rules here.', + status: 'open', + tokens: [ + { id: 'token-1', title: 'Yes', price: 0.5 }, + { id: 'token-2', title: 'No', price: 0.5 }, + ], + volume: 1000000, + }, + ], + }); + + setupPredictMarketDetailsTest(singleOutcomeMarket); + + const aboutTab = screen.getByTestId('predict-market-details-tab-bar-tab-1'); + fireEvent.press(aboutTab); + + expect( + screen.queryByText('predict.market_details.outcome_rules'), + ).toBeNull(); + }); +}); +``` + +--- + +## Summary + +| Option | Implementation | Condition | +|--------|---------------|-----------| +| **1** | `PredictOutcomeRules` in each outcome | `outcome.description` exists | +| **2** | Outcome rules section in About tab | `market.outcomes.length > 1` | +| **3** | Warning banner with Polymarket link | Always shown | + +### Key Simplification + +Instead of comparing strings (`outcome.description !== market.description`), we use a simple check: + +- **Multi-outcome markets**: Show outcome-specific rules (event description is always a summary) +- **Single-outcome markets**: Skip outcome rules section (event description = outcome rules) + +This avoids string comparison edge cases and is more intuitive. + diff --git a/locales/languages/en.json b/locales/languages/en.json index 578239ee393b..b8b298ce80c0 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1979,7 +1979,13 @@ "outcome_at_price": "{{outcome}} at {{price}}¢", "fee_exemption": "We don't charge any fees on this market.", "ended": "Ended", - "resolved_early": "Resolved early" + "resolved_early": "Resolved early", + "rules": "Rules", + "rules_disclaimer": "For complete market rules and resolution details, visit Polymarket.", + "view_rules": "View full rules", + "outcome_rules": "Outcome Rules", + "show_rules": "Show rules", + "hide_rules": "Hide rules" }, "tab": { "no_predictions_description": "Your predictions will appear here, showing your stake and market movement.", From b70b07dda5d964d6a01e522eb77ba3624feadeba Mon Sep 17 00:00:00 2001 From: michaellwin Date: Wed, 31 Dec 2025 09:57:45 +0100 Subject: [PATCH 2/2] refactor: update PredictMarketDetails to display outcome rules and improve UI - Modified the `renderAboutSection` to show the first outcome's description as the representative rules for multi-outcome markets, avoiding duplication of event descriptions. - Updated test cases in `PredictMarketDetails.test.tsx` to reflect changes in rules display and ensure proper functionality. - Adjusted localization strings to include new rules and disclaimer texts. - Removed outdated comments and cleaned up the code for better readability. --- .../PredictMarketDetails.test.tsx | 41 +- .../PredictMarketDetails.tsx | 64 +- .../predict-market-rules-implementation.md | 639 ------------------ locales/languages/en.json | 1 + 4 files changed, 39 insertions(+), 706 deletions(-) delete mode 100644 docs/predict/predict-market-rules-implementation.md diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx index 33f356323419..fc06af1613d8 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx +++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx @@ -3455,7 +3455,7 @@ describe('PredictMarketDetails', () => { fireEvent.press(aboutTab); expect( - screen.getByText('predict.market_details.rules'), + screen.getByText('predict.market_details.rules_more_info'), ).toBeOnTheScreen(); expect( screen.getByText('predict.market_details.rules_disclaimer'), @@ -3502,7 +3502,7 @@ describe('PredictMarketDetails', () => { }); }); - it('displays outcome rules for multi-outcome markets', () => { + it('displays first outcome description as rules for multi-outcome markets', () => { const multiOutcomeMarket = createMockMarket({ description: 'Event level description', outcomes: [ @@ -3540,42 +3540,19 @@ describe('PredictMarketDetails', () => { ); fireEvent.press(aboutTab); + // Event description is NOT shown for multi-outcome markets (to avoid duplication) + expect(screen.queryByText('Event level description')).toBeNull(); + + // Rules section shows first outcome's description only (not repeated per-outcome) expect( - screen.getByText('predict.market_details.outcome_rules'), + screen.getByText('predict.market_details.rules'), ).toBeOnTheScreen(); - expect(screen.getByText('↑ $100,000')).toBeOnTheScreen(); expect( screen.getByText('Resolves Yes if BTC reaches $100k on Binance.'), ).toBeOnTheScreen(); - }); - - it('hides outcome rules section for single-outcome markets', () => { - const singleOutcomeMarket = createMockMarket({ - outcomes: [ - { - id: 'outcome-1', - title: 'Will it happen?', - description: 'Market rules here.', - status: 'open', - tokens: [ - { id: 'token-1', title: 'Yes', price: 0.5 }, - { id: 'token-2', title: 'No', price: 0.5 }, - ], - volume: 1000000, - }, - ], - }); - - setupPredictMarketDetailsTest(singleOutcomeMarket); - - // Single-outcome markets only have About tab at index 0 - const aboutTab = screen.getByTestId( - 'predict-market-details-tab-bar-tab-0', - ); - fireEvent.press(aboutTab); - + // Second outcome's description is NOT shown (avoiding repetition) expect( - screen.queryByText('predict.market_details.outcome_rules'), + screen.queryByText('Resolves Yes if BTC reaches $120k on Binance.'), ).toBeNull(); }); }); diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx index 221f116273ad..0b5275975bb1 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx +++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx @@ -918,6 +918,9 @@ const PredictMarketDetails: React.FC = () => { }; const renderAboutSection = () => { + // Get the first outcome's description as the representative rules + // (for multi-outcome markets, all outcomes have similar rules with different thresholds) + const outcomeRules = market?.outcomes?.find((o) => o.description)?.description; const hasMultipleOutcomes = (market?.outcomes?.length ?? 0) > 1; return ( @@ -1039,45 +1042,36 @@ const PredictMarketDetails: React.FC = () => { - {/* Event Description */} - - {market?.description} - + {/* Event Description - only show for single-outcome markets */} + {/* For multi-outcome markets, skip event description to avoid duplication with outcome rules */} + {!hasMultipleOutcomes && ( + + {market?.description} + + )} - {/* Outcome Rules (multi-outcome markets only) */} - {hasMultipleOutcomes && ( - <> - - + {/* Rules - show first outcome's detailed description (representative of all outcomes) */} + {outcomeRules && ( + + {/* Only show divider if event description was shown above */} + {!hasMultipleOutcomes && ( + + )} + + {strings('predict.market_details.rules')} + + - {strings('predict.market_details.outcome_rules')} + {outcomeRules} - {market?.outcomes - ?.filter((o) => o.description) - .map((outcome) => ( - - - {outcome.groupItemTitle || outcome.title} - - - {outcome.description} - - - ))} - + )} {/* Disclaimer with Link */} @@ -1096,7 +1090,7 @@ const PredictMarketDetails: React.FC = () => { variant={TextVariant.BodySmBold} color={TextColor.WarningDefault} > - {strings('predict.market_details.rules')} + {strings('predict.market_details.rules_more_info')} diff --git a/docs/predict/predict-market-rules-implementation.md b/docs/predict/predict-market-rules-implementation.md deleted file mode 100644 index 5818e8115f58..000000000000 --- a/docs/predict/predict-market-rules-implementation.md +++ /dev/null @@ -1,639 +0,0 @@ -# Implementation Plan: Display Market Rules - -## Problem Summary - -MetaMask shows only **event-level description** (vague) instead of **market-level rules** (detailed). The data is already fetched into `PredictOutcome.description` but never displayed. - -### Example - -| Market | Event Description (shown) | Outcome Rules (not shown) | -|--------|---------------------------|---------------------------| -| "What price will Bitcoin hit in 2025?" | "This is a market group over whit prices Bitcoin will hit in 2025." | "This market will immediately resolve to 'Yes' if any Binance 1 minute candle for Bitcoin (BTCUSDT)..." | - ---- - -## Solution Overview - -| Option | Description | -|--------|-------------| -| **1** | Show expandable rules per outcome in `PredictMarketOutcome` component | -| **2** | Display outcome-specific rules in About tab for multi-outcome markets | -| **3** | Add prominent disclaimer with link to Polymarket | - ---- - -## Changes Summary - -| File | Change | -|------|--------| -| `locales/languages/en.json` | Add new i18n strings | -| `PredictMarketDetails.tsx` | Options 2 & 3: Update About section | -| `PredictOutcomeRules.tsx` | **NEW**: Collapsible rules component | -| `PredictOutcomeRules.test.tsx` | **NEW**: Tests for rules component | -| `PredictMarketOutcome.tsx` | Option 1: Add rules expansion | -| `PredictMarketDetails.test.tsx` | Add tests for About section changes | - ---- - -## 1. Add i18n Strings - -**File**: `locales/languages/en.json` - -Add to the `predict.market_details` section: - -```json -"rules": "Rules", -"rules_disclaimer": "For complete market rules and resolution details, visit Polymarket.", -"view_rules": "View full rules", -"outcome_rules": "Outcome Rules", -"show_rules": "Show rules", -"hide_rules": "Hide rules" -``` - ---- - -## 2. Create Collapsible Rules Component (Option 1) - -### New File: `app/components/UI/Predict/components/PredictOutcomeRules/PredictOutcomeRules.tsx` - -```tsx -import React, { useState } from 'react'; -import { Pressable } from 'react-native'; -import { - Box, - BoxFlexDirection, - BoxAlignItems, - Text, - TextColor, - TextVariant, -} from '@metamask/design-system-react-native'; -import Icon, { - IconName, - IconSize, -} from '../../../../../component-library/components/Icons/Icon'; -import { strings } from '../../../../../../locales/i18n'; -import { useTheme } from '../../../../../util/theme'; - -interface PredictOutcomeRulesProps { - description: string; - title?: string; -} - -const PredictOutcomeRules: React.FC = ({ - description, - title, -}) => { - const [isExpanded, setIsExpanded] = useState(false); - const { colors } = useTheme(); - - if (!description) return null; - - return ( - - setIsExpanded(!isExpanded)}> - - - - {isExpanded - ? strings('predict.market_details.hide_rules') - : strings('predict.market_details.show_rules')} - - - - - {isExpanded && ( - - {title && ( - - {title} - - )} - - {description} - - - )} - - ); -}; - -export default PredictOutcomeRules; -``` - -### New File: `app/components/UI/Predict/components/PredictOutcomeRules/index.ts` - -```ts -export { default } from './PredictOutcomeRules'; -``` - ---- - -## 3. Update PredictMarketOutcome (Option 1) - -**File**: `app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.tsx` - -Add import: - -```tsx -import PredictOutcomeRules from '../PredictOutcomeRules'; -``` - -Add rules display below the button container (before closing ``): - -```tsx - {!isClosed && ( - - {/* ... existing buttons ... */} - - )} - {outcome.description && ( - - )} - - ); -``` - ---- - -## 4. Update About Section (Options 2 & 3) - -**File**: `app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx` - -### 4.1 Add Polymarket Market Link Handler - -Add after `handlePolymarketResolution` (around line 617): - -```tsx -const handleViewMarketOnPolymarket = useCallback(() => { - if (!market?.slug) return; - InteractionManager.runAfterInteractions(() => { - navigation.navigate('Webview', { - screen: 'SimpleWebview', - params: { - url: `https://polymarket.com/event/${market.slug}`, - title: market.title, - }, - }); - }); -}, [navigation, market?.slug, market?.title]); -``` - -### 4.2 Replace renderAboutSection - -Replace the existing `renderAboutSection` function with: - -```tsx -const renderAboutSection = () => { - const hasMultipleOutcomes = (market?.outcomes?.length ?? 0) > 1; - - return ( - - {/* Market Stats */} - - - - - - {strings('predict.market_details.volume')} - - - - ${formatVolume(market?.outcomes[0].volume || 0)} - - - - - - - {strings('predict.market_details.end_date')} - - - - {market?.endDate - ? new Date(market?.endDate).toLocaleDateString() - : 'N/A'} - - - - - - - {strings('predict.market_details.resolution_details')} - - - - - - Polymarket - - - - - - - - - - {/* Event Description */} - - {market?.description} - - - {/* Option 2: Outcome Rules (multi-outcome markets only) */} - {hasMultipleOutcomes && ( - <> - - - - {strings('predict.market_details.outcome_rules')} - - {market?.outcomes - ?.filter((o) => o.description) - .map((outcome) => ( - - - {outcome.groupItemTitle || outcome.title} - - - {outcome.description} - - - ))} - - - )} - - {/* Option 3: Disclaimer with Link */} - - - - - {strings('predict.market_details.rules')} - - - - {strings('predict.market_details.rules_disclaimer')} - - - - - {strings('predict.market_details.view_rules')} - - - - - - - ); -}; -``` - ---- - -## 5. Tests - -### 5.1 PredictOutcomeRules Tests - -**New File**: `app/components/UI/Predict/components/PredictOutcomeRules/PredictOutcomeRules.test.tsx` - -```tsx -import React from 'react'; -import { fireEvent, screen } from '@testing-library/react-native'; -import renderWithProvider from '../../../../../util/test/renderWithProvider'; -import { backgroundState } from '../../../../../util/test/initial-root-state'; -import PredictOutcomeRules from '.'; - -const initialState = { - engine: { backgroundState }, -}; - -describe('PredictOutcomeRules', () => { - const mockDescription = 'This market resolves to Yes if BTC hits $100k.'; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('renders nothing when description is empty', () => { - renderWithProvider(, { - state: initialState, - }); - - expect(screen.queryByText('predict.market_details.show_rules')).toBeNull(); - }); - - it('renders collapsed state initially', () => { - renderWithProvider( - , - { state: initialState }, - ); - - expect( - screen.getByText('predict.market_details.show_rules'), - ).toBeOnTheScreen(); - expect(screen.queryByText(mockDescription)).toBeNull(); - }); - - it('expands to show rules when pressed', () => { - renderWithProvider( - , - { state: initialState }, - ); - - fireEvent.press(screen.getByText('predict.market_details.show_rules')); - - expect( - screen.getByText('predict.market_details.hide_rules'), - ).toBeOnTheScreen(); - expect(screen.getByText(mockDescription)).toBeOnTheScreen(); - }); - - it('collapses when pressed again', () => { - renderWithProvider( - , - { state: initialState }, - ); - - fireEvent.press(screen.getByText('predict.market_details.show_rules')); - fireEvent.press(screen.getByText('predict.market_details.hide_rules')); - - expect( - screen.getByText('predict.market_details.show_rules'), - ).toBeOnTheScreen(); - expect(screen.queryByText(mockDescription)).toBeNull(); - }); - - it('displays title when provided', () => { - renderWithProvider( - , - { state: initialState }, - ); - - fireEvent.press(screen.getByText('predict.market_details.show_rules')); - - expect(screen.getByText('BTC $100k')).toBeOnTheScreen(); - }); -}); -``` - -### 5.2 PredictMarketDetails Tests - -**File**: `app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx` - -Add new describe block: - -```tsx -describe('About Tab - Rules Display', () => { - it('displays rules disclaimer with Polymarket link', () => { - setupPredictMarketDetailsTest(); - - const aboutTab = screen.getByTestId('predict-market-details-tab-bar-tab-1'); - fireEvent.press(aboutTab); - - expect(screen.getByText('predict.market_details.rules')).toBeOnTheScreen(); - expect( - screen.getByText('predict.market_details.rules_disclaimer'), - ).toBeOnTheScreen(); - expect( - screen.getByText('predict.market_details.view_rules'), - ).toBeOnTheScreen(); - }); - - it('navigates to Polymarket event page when view rules is pressed', () => { - const { mockNavigate } = setupPredictMarketDetailsTest(); - - const aboutTab = screen.getByTestId('predict-market-details-tab-bar-tab-1'); - fireEvent.press(aboutTab); - - const viewRulesButton = screen.getByText('predict.market_details.view_rules'); - act(() => { - fireEvent.press(viewRulesButton); - }); - - const callback = - runAfterInteractionsCallbacks[runAfterInteractionsCallbacks.length - 1]; - act(() => { - callback?.(); - }); - - expect(mockNavigate).toHaveBeenCalledWith('Webview', { - screen: 'SimpleWebview', - params: { - url: expect.stringContaining('polymarket.com/event/'), - title: expect.any(String), - }, - }); - }); - - it('displays outcome rules for multi-outcome markets', () => { - const multiOutcomeMarket = createMockMarket({ - description: 'Event level description', - outcomes: [ - { - id: 'outcome-1', - title: 'BTC $100k', - groupItemTitle: '↑ $100,000', - description: 'Resolves Yes if BTC reaches $100k on Binance.', - status: 'open', - tokens: [ - { id: 'token-1', title: 'Yes', price: 0.65 }, - { id: 'token-2', title: 'No', price: 0.35 }, - ], - volume: 1000000, - }, - { - id: 'outcome-2', - title: 'BTC $120k', - groupItemTitle: '↑ $120,000', - description: 'Resolves Yes if BTC reaches $120k on Binance.', - status: 'open', - tokens: [ - { id: 'token-3', title: 'Yes', price: 0.25 }, - { id: 'token-4', title: 'No', price: 0.75 }, - ], - volume: 500000, - }, - ], - }); - - setupPredictMarketDetailsTest(multiOutcomeMarket); - - const aboutTab = screen.getByTestId('predict-market-details-tab-bar-tab-1'); - fireEvent.press(aboutTab); - - expect( - screen.getByText('predict.market_details.outcome_rules'), - ).toBeOnTheScreen(); - expect(screen.getByText('↑ $100,000')).toBeOnTheScreen(); - expect( - screen.getByText('Resolves Yes if BTC reaches $100k on Binance.'), - ).toBeOnTheScreen(); - }); - - it('hides outcome rules section for single-outcome markets', () => { - const singleOutcomeMarket = createMockMarket({ - outcomes: [ - { - id: 'outcome-1', - title: 'Will it happen?', - description: 'Market rules here.', - status: 'open', - tokens: [ - { id: 'token-1', title: 'Yes', price: 0.5 }, - { id: 'token-2', title: 'No', price: 0.5 }, - ], - volume: 1000000, - }, - ], - }); - - setupPredictMarketDetailsTest(singleOutcomeMarket); - - const aboutTab = screen.getByTestId('predict-market-details-tab-bar-tab-1'); - fireEvent.press(aboutTab); - - expect( - screen.queryByText('predict.market_details.outcome_rules'), - ).toBeNull(); - }); -}); -``` - ---- - -## Summary - -| Option | Implementation | Condition | -|--------|---------------|-----------| -| **1** | `PredictOutcomeRules` in each outcome | `outcome.description` exists | -| **2** | Outcome rules section in About tab | `market.outcomes.length > 1` | -| **3** | Warning banner with Polymarket link | Always shown | - -### Key Simplification - -Instead of comparing strings (`outcome.description !== market.description`), we use a simple check: - -- **Multi-outcome markets**: Show outcome-specific rules (event description is always a summary) -- **Single-outcome markets**: Skip outcome rules section (event description = outcome rules) - -This avoids string comparison edge cases and is more intuitive. - diff --git a/locales/languages/en.json b/locales/languages/en.json index b8b298ce80c0..29c4a6dc4523 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1981,6 +1981,7 @@ "ended": "Ended", "resolved_early": "Resolved early", "rules": "Rules", + "rules_more_info": "Disclaimer", "rules_disclaimer": "For complete market rules and resolution details, visit Polymarket.", "view_rules": "View full rules", "outcome_rules": "Outcome Rules",