diff --git a/app/components/UI/Predict/hooks/useFeaturedCarouselData.test.ts b/app/components/UI/Predict/hooks/useFeaturedCarouselData.test.ts index 1df874fc07d..c294607edd4 100644 --- a/app/components/UI/Predict/hooks/useFeaturedCarouselData.test.ts +++ b/app/components/UI/Predict/hooks/useFeaturedCarouselData.test.ts @@ -103,6 +103,26 @@ describe('useFeaturedCarouselData', () => { expect(result.current.error).toBeNull(); }); + it('filters child more-market cards', async () => { + const { Wrapper } = createWrapper(); + const parentMarket = createMockMarket({ id: 'parent-market' }); + const childMarket = createMockMarket({ + id: 'child-market', + parentMarketId: 'parent-market', + }); + mockGetCarouselMarkets.mockResolvedValue([parentMarket, childMarket]); + + const { result } = renderHook(() => useFeaturedCarouselData(), { + wrapper: Wrapper, + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.markets).toEqual([parentMarket]); + }); + it('returns error when controller throws', async () => { const { Wrapper } = createWrapper(); mockGetCarouselMarkets.mockRejectedValue(new Error('Network error')); diff --git a/app/components/UI/Predict/hooks/useFeaturedCarouselData.ts b/app/components/UI/Predict/hooks/useFeaturedCarouselData.ts index 446eb4bae85..d742c51a20c 100644 --- a/app/components/UI/Predict/hooks/useFeaturedCarouselData.ts +++ b/app/components/UI/Predict/hooks/useFeaturedCarouselData.ts @@ -7,6 +7,7 @@ import { predictQueries } from '../queries'; import { selectPredictUpDownEnabledFlag } from '../selectors/featureFlags'; import type { PredictMarket } from '../types'; import { isCryptoUpDown } from '../utils/cryptoUpDown'; +import { filterStandaloneMarkets } from '../utils/feed'; import { ensureError } from '../utils/predictErrorHandler'; export interface UseFeaturedCarouselDataResult { @@ -40,7 +41,7 @@ export const useFeaturedCarouselData = (): UseFeaturedCarouselDataResult => { }, [query.error]); const markets = useMemo(() => { - const data = query.data ?? []; + const data = filterStandaloneMarkets(query.data ?? []); if (upDownEnabled) { return data; } diff --git a/app/components/UI/Predict/hooks/usePredictMarketData.test.tsx b/app/components/UI/Predict/hooks/usePredictMarketData.test.tsx index 6396f2c70a4..8ed9da26f57 100644 --- a/app/components/UI/Predict/hooks/usePredictMarketData.test.tsx +++ b/app/components/UI/Predict/hooks/usePredictMarketData.test.tsx @@ -219,6 +219,63 @@ describe('usePredictMarketData', () => { ); }); + it('filters child more-market cards without disabling pagination', async () => { + const rawMarkets = Array.from({ length: 20 }, (_, index) => ({ + ...mockMarketData[0], + id: `market-${index}`, + slug: `market-${index}`, + parentMarketId: index >= 18 ? 'parent-market' : undefined, + })); + mockGetMarkets.mockResolvedValue(rawMarkets); + + const { result } = renderHook(() => usePredictMarketData({ pageSize: 20 })); + + await waitFor(() => { + expect(result.current.isFetching).toBe(false); + }); + + expect(result.current.marketData).toHaveLength(18); + expect(result.current.marketData.map((market) => market.id)).toEqual( + rawMarkets.slice(0, 18).map((market) => market.id), + ); + expect(result.current.hasMore).toBe(true); + }); + + it('uses raw page offsets when loading more after child cards are filtered', async () => { + const firstRawPage = Array.from({ length: 20 }, (_, index) => ({ + ...mockMarketData[0], + id: `first-page-market-${index}`, + slug: `first-page-market-${index}`, + parentMarketId: index >= 18 ? 'parent-market' : undefined, + })); + const secondRawPage = Array.from({ length: 5 }, (_, index) => ({ + ...mockMarketData[0], + id: `second-page-market-${index}`, + slug: `second-page-market-${index}`, + })); + + mockGetMarkets + .mockResolvedValueOnce(firstRawPage) + .mockResolvedValueOnce(secondRawPage); + + const { result } = renderHook(() => usePredictMarketData({ pageSize: 20 })); + + await waitFor(() => { + expect(result.current.isFetching).toBe(false); + }); + + await act(async () => { + await result.current.fetchMore(); + }); + + expect(mockGetMarkets).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ limit: 20, offset: 20 }), + ); + expect(result.current.marketData).toHaveLength(23); + expect(result.current.hasMore).toBe(false); + }); + it('handle null market data', async () => { mockGetMarkets.mockResolvedValue(null); diff --git a/app/components/UI/Predict/hooks/usePredictMarketData.tsx b/app/components/UI/Predict/hooks/usePredictMarketData.tsx index 0b88c4f49d2..cc639a1cbc0 100644 --- a/app/components/UI/Predict/hooks/usePredictMarketData.tsx +++ b/app/components/UI/Predict/hooks/usePredictMarketData.tsx @@ -13,6 +13,7 @@ import Logger from '../../../../util/Logger'; import { PREDICT_CONSTANTS } from '../constants/errors'; import { ensureError } from '../utils/predictErrorHandler'; import { PredictCategory, PredictMarket } from '../types'; +import { filterStandaloneMarkets } from '../utils/feed'; export interface UsePredictMarketDataOptions { q?: string; @@ -147,12 +148,13 @@ export const usePredictMarketData = ( const hasMoreData = markets.length >= pageSize; setHasMore(hasMoreData); + const visibleMarkets = filterStandaloneMarkets(markets); if (isLoadMore) { setMarketData((prevData) => { // Use a Set to efficiently deduplicate by ID const existingIds = new Set(prevData.map((event) => event.id)); - const newEvents = markets.filter( + const newEvents = visibleMarkets.filter( (event) => !existingIds.has(event.id), ); const accumulated = [...prevData, ...newEvents]; @@ -162,7 +164,7 @@ export const usePredictMarketData = ( currentOffsetRef.current += pageSize; } else { // Replace data for initial load or refresh - setMarketData(refine ? refine(markets) : markets); + setMarketData(refine ? refine(visibleMarkets) : visibleMarkets); setCurrentOffset(pageSize); currentOffsetRef.current = pageSize; } diff --git a/app/components/UI/Predict/providers/polymarket/types.ts b/app/components/UI/Predict/providers/polymarket/types.ts index b2ded81342d..4025aa8f99c 100644 --- a/app/components/UI/Predict/providers/polymarket/types.ts +++ b/app/components/UI/Predict/providers/polymarket/types.ts @@ -124,7 +124,7 @@ export interface PolymarketApiEvent { period?: PredictGamePeriod; live?: boolean; ended?: boolean; - parentEventId?: string | number; + parentEventId?: string | number | null; } export interface PolymarketApiActivity { diff --git a/app/components/UI/Predict/providers/polymarket/utils.test.ts b/app/components/UI/Predict/providers/polymarket/utils.test.ts index ed337e1c332..152fa6c21a5 100644 --- a/app/components/UI/Predict/providers/polymarket/utils.test.ts +++ b/app/components/UI/Predict/providers/polymarket/utils.test.ts @@ -687,4 +687,28 @@ describe('polymarket utils', () => { }), ).resolves.toBe(false); }); + + it('preserves parent market id when parsing Polymarket events', () => { + const event: PolymarketApiEvent = { + id: 'child-event', + slug: 'child-event', + title: 'Child Event', + description: 'Child event description', + icon: '', + closed: false, + series: [], + markets: [], + tags: [], + liquidity: 0, + volume: 0, + parentEventId: 'parent-market', + }; + + expect(parsePolymarketEvents([event], 'trending')).toEqual([ + expect.objectContaining({ + id: 'child-event', + parentMarketId: 'parent-market', + }), + ]); + }); }); diff --git a/app/components/UI/Predict/providers/polymarket/utils.ts b/app/components/UI/Predict/providers/polymarket/utils.ts index 35864170d7b..4330391309e 100644 --- a/app/components/UI/Predict/providers/polymarket/utils.ts +++ b/app/components/UI/Predict/providers/polymarket/utils.ts @@ -1111,6 +1111,9 @@ export const parsePolymarketEvents = ( volume: event.volume, game, ...(seriesData && { series: seriesData }), + ...(event.parentEventId !== undefined && { + parentMarketId: event.parentEventId, + }), }; }, ); diff --git a/app/components/UI/Predict/types/index.ts b/app/components/UI/Predict/types/index.ts index 5c51f92f4a8..ed558310d40 100644 --- a/app/components/UI/Predict/types/index.ts +++ b/app/components/UI/Predict/types/index.ts @@ -116,6 +116,7 @@ export type PredictMarket = { volume: number; game?: PredictMarketGame; series?: PredictSeries; + parentMarketId?: string | number | null; childMarketIds?: string[]; }; diff --git a/app/components/UI/Predict/utils/feed.test.ts b/app/components/UI/Predict/utils/feed.test.ts index 1b2958b720d..47a80c6d110 100644 --- a/app/components/UI/Predict/utils/feed.test.ts +++ b/app/components/UI/Predict/utils/feed.test.ts @@ -1,4 +1,4 @@ -import { deduplicateSeriesMarkets } from './feed'; +import { deduplicateSeriesMarkets, filterStandaloneMarkets } from './feed'; import { Recurrence, type PredictMarket } from '../types'; const createMockMarket = ( @@ -122,3 +122,31 @@ describe('deduplicateSeriesMarkets', () => { expect(result).toEqual([single]); }); }); + +describe('filterStandaloneMarkets', () => { + it('removes markets with a parent market id', () => { + const parent = createMockMarket('parent'); + const emptyParent = createMockMarket('empty-parent', { + parentMarketId: '', + }); + const nullParent = createMockMarket('null-parent', { + parentMarketId: null, + }); + const child = createMockMarket('child', { + parentMarketId: 'parent', + }); + const numericChild = createMockMarket('numeric-child', { + parentMarketId: 123, + }); + + const result = filterStandaloneMarkets([ + parent, + emptyParent, + nullParent, + child, + numericChild, + ]); + + expect(result).toEqual([parent, emptyParent, nullParent]); + }); +}); diff --git a/app/components/UI/Predict/utils/feed.ts b/app/components/UI/Predict/utils/feed.ts index af5d1527106..de389558984 100644 --- a/app/components/UI/Predict/utils/feed.ts +++ b/app/components/UI/Predict/utils/feed.ts @@ -1,6 +1,21 @@ import type { PredictMarket } from '../types'; import { isCryptoUpDown } from './cryptoUpDown'; +export function isStandaloneMarket(market: PredictMarket): boolean { + const { parentMarketId } = market; + return ( + parentMarketId === undefined || + parentMarketId === null || + String(parentMarketId).trim() === '' + ); +} + +export function filterStandaloneMarkets( + markets: PredictMarket[], +): PredictMarket[] { + return markets.filter(isStandaloneMarket); +} + /** * Keeps only the first occurrence of each Crypto Up/Down series slug. * Non-Up/Down markets pass through unchanged.