diff --git a/src/app/pages/ArticlePage/ArticlePage.styles.ts b/src/app/pages/ArticlePage/ArticlePage.styles.ts index 784d53d8fb3..8783aad40c6 100644 --- a/src/app/pages/ArticlePage/ArticlePage.styles.ts +++ b/src/app/pages/ArticlePage/ArticlePage.styles.ts @@ -243,4 +243,14 @@ export default { padding: 0, }, }), + mobileOJContainer: ({ mq }: Theme) => + css({ + [mq.GROUP_4_MIN_WIDTH]: { + display: 'none', + }, + }), + midArticleOJ: () => + css({ + marginBottom: `${pixelsToRem(40)}rem`, + }), }; diff --git a/src/app/pages/ArticlePage/ArticlePage.tsx b/src/app/pages/ArticlePage/ArticlePage.tsx index e66dce0d0e5..c4366ce8e49 100644 --- a/src/app/pages/ArticlePage/ArticlePage.tsx +++ b/src/app/pages/ArticlePage/ArticlePage.tsx @@ -1,4 +1,4 @@ -import { use, useState, useCallback } from 'react'; +import { Fragment, ReactNode, useState, useCallback, use } from 'react'; import { useTheme } from '@emotion/react'; import useToggle from '#hooks/useToggle'; import useMediaQuery from '#hooks/useMediaQuery'; @@ -59,6 +59,7 @@ import ContinueReadingButton, { ContinueReadingButtonProps, } from '#app/components/ContinueReadingButton'; import SaveArticleButton from '#app/components/SaveArticleButton'; +import FeaturesAnalysis from '#containers/CpsFeaturesAnalysis'; import AccountPromotionalBannerExperiment from '#app/components/Account/AccountPromotionalBannerExperiment'; import ElectionBanner from './ElectionBanner'; import ArticleMessageBanner from './ArticleMessageBanner'; @@ -84,6 +85,9 @@ import RelatedContentSection from '../../components/RelatedContentSection'; import TopicDiscovery from '../../components/TopicDiscovery'; import Disclaimer from '../../components/Disclaimer'; import SecondaryColumn from './SecondaryColumn'; +import useMobileOJComponentOrder, { + useDebugVariant, +} from './useMobileOJComponentOrder'; import styles from './ArticlePage.styles'; import { ComponentToRenderProps, TimeStampProps } from './types'; import ArticleHeadline from './ArticleHeadline'; @@ -92,6 +96,12 @@ import { isPortraitVideoUnderHeadline, } from '../../components/MediaLoader/utils/isPortraitVideo'; import LocationBasedTopicOJ from '../../components/LocationBasedTopicOJ'; +import { + OJComponentKey, + SEARCH_MID_ARTICLE_COMPONENT, + SearchVariant, +} from './searchReferrerComponentOrder'; +import TopStoriesSection from './PagePromoSections/TopStoriesSection'; const getImageComponent = (preloadLeadImageToggle: boolean) => (props: ComponentToRenderProps) => ( @@ -161,6 +171,7 @@ const getWsojComponent = ({ }) => ( ); + const DisclaimerWithPaddingOverride = (props: ComponentToRenderProps) => ( ); @@ -292,6 +303,60 @@ const ArticlePage = ({ pageData }: { pageData: Article }) => { ?.split(':') ?.includes('topcat'); + const showCountryCuration = Boolean( + !isAmp && + !isLite && + !isApp && + countryCurationEnabled && + pageData?.countryCuration?.summaries?.length, + ); + + const hasRelatedContent = blocks.some( + block => block.type === 'relatedContent', + ); + + const getSearchMidArticleOJ = ({ + data, + experimentProps, + searchVariant, + }: { + data: Recommendation[]; + experimentProps?: ComponentExperimentProps | null; + searchVariant: SearchVariant | null; + }) => { + const midarticleOJ = searchVariant + ? SEARCH_MID_ARTICLE_COMPONENT[searchVariant] + : null; + switch (midarticleOJ) { + case 'mostRead': + return ; + + case 'relatedContent': + return hasRelatedContent ? ( +
+ +
+ ) : ( + + ); + + case 'topicDiscovery': + return ; + + case 'locationBasedOJ': + return showCountryCuration ? ( +
+ +
+ ) : ( + + ); + + default: + return getWsojComponent({ data, experimentProps }); + } + }; + const showPortraitVideoCarousel = Boolean( pageData?.portraitVideoItems?.portraitVideo?.blocks?.length && articlePortraitVideoEnabled, @@ -350,6 +415,9 @@ const ArticlePage = ({ pageData }: { pageData: Article }) => { promoImageRawBlock?.model as { locator?: string } | undefined )?.locator; + const searchVariant = useDebugVariant(); + const mobileOJOrder = useMobileOJComponentOrder(searchVariant); + const componentsToRender = { visuallyHiddenHeadline, headline: getHeadlineComponent, @@ -376,10 +444,11 @@ const ArticlePage = ({ pageData }: { pageData: Article }) => { group: gist, links: ArticleLinksBlock, mpu: getMpuComponent(allowAdvertising), + // renders wsoj if user is on desktop, otherwise renders a chosen OJ based on search referrer experiment on mobile wsoj: ({ data }: { data: Recommendation[] }) => - getWsojComponent({ - data, - }), + !isDesktopViewport + ? getSearchMidArticleOJ({ data, searchVariant }) + : getWsojComponent({ data }), disclaimer: DisclaimerWithPaddingOverride, podcastPromo: getPodcastPromoComponent(podcastPromoEnabled), ...(showContinueReadingButton && { @@ -404,8 +473,16 @@ const ArticlePage = ({ pageData }: { pageData: Article }) => { const showTopicDiscovery = topicDiscoveryEnabled && !isAmp && !isLite; + // Topic Discovery shows in the midarticle position for one variant + // We want to hide RelatedTopics when this happens + const topicDiscoveryInMidArticlePosition = + !isDesktopViewport && searchVariant === 'variant_5_recommended_mid'; + const showRelatedTopicsComponent = Boolean( - showRelatedTopics && topics.length > 0 && !showTopicDiscovery, + showRelatedTopics && + topics.length > 0 && + !showTopicDiscovery && + !topicDiscoveryInMidArticlePosition, ); const showMediaCuration = Boolean( @@ -417,13 +494,112 @@ const ArticlePage = ({ pageData }: { pageData: Article }) => { articleVideoCurationEnabled, ); - const showCountryCuration = Boolean( - !isAmp && - !isLite && - !isApp && - countryCurationEnabled && - pageData?.countryCuration?.summaries?.length, - ); + const topStoriesContent = pageData?.secondaryColumn?.topStories; + const featuresContent = pageData?.secondaryColumn?.features; + + const getTopicDiscoverySlot = () => { + if (showTopicDiscovery) { + return ( + + ); + } + if (showRelatedTopicsComponent) { + return ( + + ); + } + return null; + }; + + const topicDiscoverySlot = getTopicDiscoverySlot(); + + const getVideoOJComponent = (): ReactNode => { + if (showPortraitVideoCarousel) { + return ( + + ); + } + if (showMediaCuration) { + return ( +
+
+ +
+
+ ); + } + return null; + }; + + const mobileOJComponents: Record = { + mostRead: + !isApp && !isPGL ? ( + + ) : null, + topicDiscovery: topicDiscoverySlot, + relatedContent: , + videoOJ: getVideoOJComponent(), + topStories: + !isApp && !isPGL && topStoriesContent ? ( +
+ +
+ ) : null, + featuredArticles: + !isApp && !isPGL && featuresContent ? ( +
+ +
+ ) : null, + locationBasedOJ: showCountryCuration ? ( + + ) : null, + }; const shouldApplyCollapsedArticleSpacing = showContinueReadingButton && !showAllContent; @@ -500,7 +676,7 @@ const ArticlePage = ({ pageData }: { pageData: Article }) => { - {showTopicDiscovery && ( + {!mobileOJOrder && showTopicDiscovery && ( { topics={topics} /> )} - {showRelatedTopicsComponent && ( + {!mobileOJOrder && showRelatedTopicsComponent && ( { mobileDivider={false} /> )} - {showCountryCuration && } - {showPortraitVideoCarousel && ( + {!mobileOJOrder && showCountryCuration && ( + + )} + {!mobileOJOrder && showPortraitVideoCarousel && ( )} - - {showMediaCuration && ( + {!mobileOJOrder && } + {!mobileOJOrder && showMediaCuration && (
{ )}
- {!isApp && !isPGL && } + {!isApp && !isPGL && !mobileOJOrder && ( + + )}
- {!isApp && !isPGL && ( + {mobileOJOrder && ( +
+ {mobileOJOrder.map(key => ( + {mobileOJComponents[key]} + ))} +
+ )} + + {!isApp && !isPGL && !mobileOJOrder && ( { + const ChartbeatAnalytics = () => null; + return ChartbeatAnalytics; +}); +jest.mock('#app/components/ATIAnalytics', () => { + const ATIAnalytics = () => null; + return ATIAnalytics; +}); + +/* eslint-disable global-require, @typescript-eslint/no-var-requires */ +jest.mock( + '#app/components/MostRead', + () => () => + require('react').createElement('div', { 'data-testid': 'most-read' }), +); +jest.mock( + '#app/components/TopicDiscovery', + () => () => + require('react').createElement('div', { 'data-testid': 'topic-discovery' }), +); +jest.mock( + '#app/components/RelatedContentSection', + () => () => + require('react').createElement('div', { 'data-testid': 'related-content' }), +); +jest.mock( + '#app/components/PortraitVideoCarousel', + () => () => + require('react').createElement('div', { + 'data-testid': 'portrait-video-carousel', + }), +); +jest.mock( + '#app/components/LocationBasedTopicOJ', + () => () => + require('react').createElement('div', { + 'data-testid': 'location-based-topic-oj', + }), +); +/* eslint-enable global-require, @typescript-eslint/no-var-requires */ + +jest.mock('#app/components/OptimizelyPageMetrics'); +jest.mock('#app/hooks/useScrollDepthTracker', () => jest.fn(() => null)); +jest.mock('#hooks/useMediaQuery', () => jest.fn()); +jest.mock('#app/hooks/useOptimizelyVariation', () => ({ + __esModule: true, + ...jest.requireActual('#app/hooks/useOptimizelyVariation'), + default: jest.fn(), +})); +jest.mock( + '#app/legacy/containers/PageHandlers/withOptimizelyProvider/userAttributes', +); +jest.mock('#app/lib/utilities/onClient', () => ({ + __esModule: true, + default: jest.fn(), + onClient: jest.fn(() => true), +})); + +const mockOnClient = onClient as jest.MockedFunction; + +describe('useMobileOJComponentOrder', () => { + let matchMediaMock: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + mockOnClient.mockReturnValue(true); + + // Mock window.matchMedia + matchMediaMock = jest.fn().mockReturnValue({ + matches: false, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }); + + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: matchMediaMock, + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('Desktop behavior', () => { + it('should return null regardless of variant when on desktop', () => { + matchMediaMock.mockReturnValue({ + matches: false, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }); + + const { result: variant2Result } = renderHook(() => + useMobileOJComponentOrder('variant_2_recommended'), + ); + expect(variant2Result.current).toBeNull(); + + const { result: variant3Result } = renderHook(() => + useMobileOJComponentOrder('variant_3_hybrid'), + ); + expect(variant3Result.current).toBeNull(); + }); + }); + + describe('Mobile behavior with different variants', () => { + beforeEach(() => { + matchMediaMock.mockReturnValue({ + matches: true, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }); + }); + it.each([ + 'variant_1_related', + 'variant_2_recommended', + 'variant_3_hybrid', + 'variant_4_related_mid', + 'variant_5_recommended_mid', + 'variant_6_hybrid_mid', + ] as SearchVariant[])('returns the correct order for %s', searchVariant => { + const { result } = renderHook(() => + useMobileOJComponentOrder(searchVariant), + ); + + expect(result.current).toEqual(SEARCH_COMPONENT_ORDER[searchVariant]); + }); + it('returns null when no variant is provided', () => { + const { result } = renderHook(() => useMobileOJComponentOrder(null)); + expect(result.current).toBeNull(); + }); + + describe('Renderered mobile OJ components', () => { + beforeEach(() => { + matchMediaMock.mockReturnValue({ + matches: true, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }); + }); + + const OJ_TEST_IDS: Record = { + mostRead: 'most-read', + topicDiscovery: 'topic-discovery', + videoOJ: 'portrait-video-carousel', + relatedContent: 'related-content', + topStories: 'top-stories', + featuredArticles: 'features', + locationBasedOJ: 'location-based-topic-oj', + }; + + const renderVariant = (variant: SearchVariant) => { + window.history.replaceState(null, '', `?debugVariant=${variant}`); + + const pageData = { + ...articleDataNews, + countryCuration: { summaries: [{ title: 'Country' }] }, + portraitVideoItems: { portraitVideo: { blocks: [{}] } }, + secondaryColumn: { topStories: [], features: [] }, + } as unknown as Article; + + return render(createElement(ArticlePage, { pageData }), { + service: 'news', + toggles: { + topicDiscovery: { enabled: true }, + articlePortraitVideo: { enabled: true }, + locationTopicCuration: { enabled: true }, + }, + }); + }; + + afterEach(() => { + window.history.replaceState(null, '', '/'); + }); + + const getRenderedOJOrder = (container: HTMLElement) => { + const ojTestIds = Object.values(OJ_TEST_IDS); + return Array.from(container.querySelectorAll('[data-testid]')) + .map(element => element.getAttribute('data-testid')) + .filter(testId => ojTestIds.includes(testId as string)); + }; + + it.each([ + 'variant_1_related', + 'variant_2_recommended', + 'variant_3_hybrid', + 'variant_4_related_mid', + 'variant_5_recommended_mid', + 'variant_6_hybrid_mid', + ] as SearchVariant[])( + 'renders the OJ components in the configured order for %s', + async searchVariant => { + renderVariant(searchVariant); + + const container = await screen.findByTestId('mobile-oj-container'); + + const expectedOrder = SEARCH_COMPONENT_ORDER[searchVariant].map( + key => OJ_TEST_IDS[key], + ); + + expect(getRenderedOJOrder(container)).toEqual(expectedOrder); + }, + ); + }); + }); + + describe('Mid-article component ordering', () => { + it.each([ + ['variant_1_related', 'mostRead'], + ['variant_2_recommended', 'mostRead'], + ['variant_3_hybrid', 'mostRead'], + ['variant_4_related_mid', 'relatedContent'], + ['variant_5_recommended_mid', 'topicDiscovery'], + ['variant_6_hybrid_mid', 'locationBasedOJ'], + ] as const)('%o returns %o', (searchVariant, expectedComponent) => { + expect(SEARCH_MID_ARTICLE_COMPONENT[searchVariant]).toBe( + expectedComponent, + ); + }); + }); + + describe('Media query listener', () => { + it('should add a change listener to media query on client', () => { + const addEventListenerMock = jest.fn(); + matchMediaMock.mockReturnValue({ + matches: false, + addEventListener: addEventListenerMock, + removeEventListener: jest.fn(), + }); + + renderHook(() => useMobileOJComponentOrder(null)); + + expect(addEventListenerMock).toHaveBeenCalledWith( + 'change', + expect.any(Function), + ); + }); + + it('should remove listener on unmount', () => { + const removeEventListenerMock = jest.fn(); + matchMediaMock.mockReturnValue({ + matches: false, + addEventListener: jest.fn(), + removeEventListener: removeEventListenerMock, + }); + + const { unmount } = renderHook(() => useMobileOJComponentOrder(null)); + + unmount(); + + expect(removeEventListenerMock).toHaveBeenCalledWith( + 'change', + expect.any(Function), + ); + }); + + it('should update order when viewport changes from desktop to mobile', async () => { + let listenerCallback: ((e: MediaQueryListEvent) => void) | null = null; + matchMediaMock.mockImplementation(() => ({ + matches: false, + addEventListener: jest.fn((event, callback) => { + if (event === 'change') { + listenerCallback = callback; + } + }), + removeEventListener: jest.fn(), + })); + + const { result } = renderHook(() => + useMobileOJComponentOrder('variant_1_related'), + ); + + expect(result.current).toBeNull(); + + if (listenerCallback) { + await act(async () => { + listenerCallback?.({ + matches: true, + } as MediaQueryListEvent); + }); + } + + await waitFor(() => { + expect(result.current).not.toBeNull(); + }); + + expect(result.current?.[0]).toBe('topicDiscovery'); + }); + + it('should update order when viewport changes from mobile to desktop', async () => { + let listenerCallback: ((e: MediaQueryListEvent) => void) | null = null; + matchMediaMock.mockImplementation(() => ({ + matches: true, + addEventListener: jest.fn((event, callback) => { + if (event === 'change') { + listenerCallback = callback; + } + }), + removeEventListener: jest.fn(), + })); + + const { result } = renderHook(() => + useMobileOJComponentOrder('variant_4_related_mid'), + ); + + expect(result.current).not.toBeNull(); + + if (listenerCallback) { + await act(async () => { + listenerCallback?.({ + matches: false, + } as MediaQueryListEvent); + }); + } + + await waitFor(() => { + expect(result.current).toBeNull(); + }); + }); + }); + + describe('Correct media query breakpoint', () => { + it('should use GROUP_3_MAX_WIDTH_BP for the breakpoint', () => { + mockOnClient.mockReturnValue(true); + + renderHook(() => useMobileOJComponentOrder(null)); + + const callArgs = matchMediaMock.mock.calls[0][0]; + + expect(callArgs).toContain(`${GROUP_3_MAX_WIDTH_BP}rem`); + expect(callArgs).toContain('max-width'); + }); + }); +}); diff --git a/src/app/pages/ArticlePage/searchReferrerComponentOrder.ts b/src/app/pages/ArticlePage/searchReferrerComponentOrder.ts new file mode 100644 index 00000000000..f7dae5184d5 --- /dev/null +++ b/src/app/pages/ArticlePage/searchReferrerComponentOrder.ts @@ -0,0 +1,86 @@ +export type OJComponentKey = + | 'mostRead' + | 'topicDiscovery' + | 'videoOJ' + | 'relatedContent' + | 'topStories' + | 'featuredArticles' + | 'locationBasedOJ'; + +export type SearchVariant = + | 'variant_1_related' + | 'variant_2_recommended' + | 'variant_3_hybrid' + | 'variant_4_related_mid' + | 'variant_5_recommended_mid' + | 'variant_6_hybrid_mid'; + +export const SEARCH_COMPONENT_ORDER: Record = { + variant_1_related: [ + 'topicDiscovery', + 'relatedContent', + 'locationBasedOJ', + 'topStories', + 'featuredArticles', + 'videoOJ', + 'mostRead', + ], + variant_2_recommended: [ + 'topicDiscovery', + 'mostRead', + 'relatedContent', + 'featuredArticles', + 'videoOJ', + 'topStories', + 'locationBasedOJ', + ], + variant_3_hybrid: [ + 'topicDiscovery', + 'locationBasedOJ', + 'relatedContent', + 'mostRead', + 'videoOJ', + 'topStories', + 'featuredArticles', + ], + variant_4_related_mid: [ + 'topicDiscovery', + 'locationBasedOJ', + 'topStories', + 'featuredArticles', + 'videoOJ', + 'mostRead', + ], + variant_5_recommended_mid: [ + 'mostRead', + 'relatedContent', + 'featuredArticles', + 'videoOJ', + 'topStories', + 'locationBasedOJ', + ], + variant_6_hybrid_mid: [ + 'topicDiscovery', + 'relatedContent', + 'mostRead', + 'videoOJ', + 'topStories', + 'featuredArticles', + ], +}; + +export type MidArticleOJ = + | 'mostRead' + | 'topicDiscovery' + | 'relatedContent' + | 'locationBasedOJ'; + +export const SEARCH_MID_ARTICLE_COMPONENT: Record = + { + variant_1_related: 'mostRead', + variant_2_recommended: 'mostRead', + variant_3_hybrid: 'mostRead', + variant_4_related_mid: 'relatedContent', + variant_5_recommended_mid: 'topicDiscovery', + variant_6_hybrid_mid: 'locationBasedOJ', + }; diff --git a/src/app/pages/ArticlePage/useMobileOJComponentOrder.ts b/src/app/pages/ArticlePage/useMobileOJComponentOrder.ts new file mode 100644 index 00000000000..bbc7a678d4e --- /dev/null +++ b/src/app/pages/ArticlePage/useMobileOJComponentOrder.ts @@ -0,0 +1,70 @@ +import { useEffect, useState } from 'react'; +import onClient from '#app/lib/utilities/onClient'; +import { GROUP_3_MAX_WIDTH_BP } from '#app/components/ThemeProvider/mediaQueries'; +import { + OJComponentKey, + SEARCH_COMPONENT_ORDER, + SearchVariant, +} from './searchReferrerComponentOrder'; + +const getDebugVariant = (): SearchVariant | null => { + const params = new URLSearchParams(window.location.search); + const debugParam = params.get('debugVariant'); + if ( + debugParam && + Object.prototype.hasOwnProperty.call(SEARCH_COMPONENT_ORDER, debugParam) + ) { + return debugParam as SearchVariant; + } + return null; +}; + +export const useDebugVariant = (): SearchVariant | null => { + const [debugVariant, setDebugVariant] = useState(null); + + useEffect(() => { + if (!onClient()) return; + + setDebugVariant(getDebugVariant()); + }, []); + + return debugVariant; +}; + +const useMobileOJComponentOrder = ( + searchVariant: SearchVariant | null, +): OJComponentKey[] | null => { + const [order, setOrder] = useState(null); + + useEffect(() => { + if (!onClient()) return undefined; + + const mediaQuery = window.matchMedia( + `(max-width: ${GROUP_3_MAX_WIDTH_BP}rem)`, + ); + + const mobileOrder = searchVariant + ? SEARCH_COMPONENT_ORDER[searchVariant] + : null; + + const updateOrder = (matches: boolean) => { + setOrder(matches ? mobileOrder : null); + }; + + // Initial check + updateOrder(mediaQuery.matches); + + // Listen for changes and use the event's matches value + const handleChange = (event: MediaQueryListEvent) => { + updateOrder(event.matches); + }; + + mediaQuery.addEventListener('change', handleChange); + + return () => mediaQuery.removeEventListener('change', handleChange); + }, [searchVariant]); + + return order; +}; + +export default useMobileOJComponentOrder;