diff --git a/ab-testing/config/abTests.ts b/ab-testing/config/abTests.ts index 5a71df2c826..9fb9d0a837c 100644 --- a/ab-testing/config/abTests.ts +++ b/ab-testing/config/abTests.ts @@ -70,6 +70,18 @@ const ABTests: ABTest[] = [ groups: ["control", "variant"], shouldForceMetricsCollection: false, }, + { + name: "fronts-and-curation-personalised-container", + description: "Testing the a personalised container component on fronts", + owners: ["fronts.and.curation@guardian.co.uk"], + expirationDate: `2026-02-22`, + type: "server", + status: "ON", + audienceSize: 0 / 100, + audienceSpace: "A", + groups: ["control", "variant"], + shouldForceMetricsCollection: false, + }, ]; const activeABtests = ABTests.filter((test) => test.status === "ON"); diff --git a/dotcom-rendering/src/components/Card/Card.tsx b/dotcom-rendering/src/components/Card/Card.tsx index 67ac1787560..151d1df0d40 100644 --- a/dotcom-rendering/src/components/Card/Card.tsx +++ b/dotcom-rendering/src/components/Card/Card.tsx @@ -168,6 +168,7 @@ export type Props = { isInStarRatingVariant?: boolean; starRatingSize?: RatingSizeType; isInOnwardsAbTestVariant?: boolean; + isInPersonalisationVariant?: boolean; }; const starWrapper = (cardHasImage: boolean) => css` @@ -201,13 +202,16 @@ const waveformWrapper = ( left: 0; right: 0; bottom: 0; + svg { display: block; width: 100%; height: ${mediaPositionOnMobile === 'top' ? 50 : 29}px; + ${from.mobileMedium} { height: ${mediaPositionOnMobile === 'top' ? 50 : 33}px; } + ${from.tablet} { height: ${mediaPositionOnDesktop === 'top' ? 50 : 33}px; } @@ -221,12 +225,15 @@ const HorizontalDivider = () => ( border-top: 1px solid ${palette('--card-border-top')}; height: 1px; width: 50%; + ${from.tablet} { width: 100px; } + ${from.desktop} { width: 140px; } + margin-top: ${space[3]}px; } `} @@ -241,6 +248,7 @@ const podcastImageStyles = ( return css` width: 69px; height: 69px; + ${from.tablet} { width: 98px; height: 98px; @@ -254,11 +262,14 @@ const podcastImageStyles = ( return css` width: 98px; height: 98px; + ${from.tablet} { width: 120px; height: 120px; } + /** The image takes the full height on desktop, so that the waveform sticks to the bottom of the card. */ + ${from.desktop} { width: ${isHorizontalOnDesktop ? 'unset' : '120px'}; height: ${isHorizontalOnDesktop ? 'unset' : '120px'}; @@ -429,6 +440,7 @@ export const Card = ({ isInStarRatingVariant, starRatingSize = 'small', isInOnwardsAbTestVariant, + isInPersonalisationVariant, }: Props) => { const hasSublinks = supportingContent && supportingContent.length > 0; const sublinkPosition = decideSublinkPosition( @@ -612,8 +624,8 @@ export const Card = ({ /** - * Media cards have contrasting background colours. We add additional - * padding to these cards to keep the text readable. -- */ +* padding to these cards to keep the text readable. +- */ const isMediaCardOrNewsletter = isMediaCard(format) || isNewsletter; const showPill = isMediaCardOrNewsletter && !isGallerySecondaryOnward; @@ -910,6 +922,7 @@ export const Card = ({ ${until.tablet} { display: none; } + ${from.desktop} { display: none; } @@ -946,6 +959,7 @@ export const Card = ({ headlineText={headlineText} dataLinkName={resolvedDataLinkName} isExternalLink={isExternalLink} + isInPersonalisationVariant={isInPersonalisationVariant} /> {headlinePosition === 'outer' && (
void; }) => { return ( // eslint-disable-next-line jsx-a11y/anchor-has-content -- we have an aria-label attribute describing the content @@ -39,6 +43,7 @@ const InternalLink = ({ css={fauxLinkStyles} data-link-name={dataLinkName} aria-label={headlineText} + onClick={trackPersonalisationCardClick} /> ); }; @@ -47,12 +52,12 @@ const ExternalLink = ({ linkTo, headlineText, dataLinkName, - trackCardClick, + trackPersonalisationCardClick, }: { linkTo: string; headlineText: string; dataLinkName?: string; - trackCardClick?: () => void; + trackPersonalisationCardClick?: () => void; }) => { return ( // eslint-disable-next-line jsx-a11y/anchor-has-content -- we have an aria-label attribute describing the content @@ -63,7 +68,7 @@ const ExternalLink = ({ aria-label={headlineText + ' (opens in new tab)'} target="_blank" rel="noreferrer" - onClick={trackCardClick} + onClick={trackPersonalisationCardClick} /> ); }; @@ -73,6 +78,7 @@ export const CardLink = ({ headlineText, dataLinkName = 'article', //this makes sense if the link is to an article, but should this say something like "external" if it's an external link? are there any other uses/alternatives? isExternalLink, + isInPersonalisationVariant, }: Props) => { return ( <> @@ -81,6 +87,10 @@ export const CardLink = ({ linkTo={linkTo} headlineText={headlineText} dataLinkName={dataLinkName} + trackPersonalisationCardClick={() => + isInPersonalisationVariant && + trackPersonalisationClick(linkTo) + } /> )} {!isExternalLink && ( @@ -88,6 +98,10 @@ export const CardLink = ({ linkTo={linkTo} headlineText={headlineText} dataLinkName={dataLinkName} + trackPersonalisationCardClick={() => + isInPersonalisationVariant && + trackPersonalisationClick(linkTo) + } /> )} diff --git a/dotcom-rendering/src/components/Card/components/LI.tsx b/dotcom-rendering/src/components/Card/components/LI.tsx index 1657bea3f19..b34c773a886 100644 --- a/dotcom-rendering/src/components/Card/components/LI.tsx +++ b/dotcom-rendering/src/components/Card/components/LI.tsx @@ -100,6 +100,8 @@ type Props = { offsetBottomPaddingOnDivider?: boolean; /** Overrides the vertical divider colour */ verticalDividerColour?: string; + + isVisible?: boolean; }; export const LI = ({ @@ -114,6 +116,7 @@ export const LI = ({ snapAlignStart = false, offsetBottomPaddingOnDivider = false, verticalDividerColour = palette('--section-border'), + isVisible = true, }: Props) => { // Decide sizing const sizeStyles = decideSize(percentage, stretch); @@ -133,6 +136,7 @@ export const LI = ({ padSidesOnMobile && sidePaddingStylesMobile(padSidesMobileOverride), snapAlignStart && snapAlignStartStyles, + { visibility: isVisible ? 'visible' : 'hidden' }, ]} > {children} diff --git a/dotcom-rendering/src/components/DecideContainer.tsx b/dotcom-rendering/src/components/DecideContainer.tsx index 5ff6ed3bed9..2011045087c 100644 --- a/dotcom-rendering/src/components/DecideContainer.tsx +++ b/dotcom-rendering/src/components/DecideContainer.tsx @@ -6,6 +6,7 @@ import type { DCRContainerType, DCRFrontCard, DCRGroupedTrails, + PillarBucket, } from '../types/front'; import { DynamicFast } from './DynamicFast'; import { DynamicPackage } from './DynamicPackage'; @@ -27,6 +28,7 @@ import { FlexibleGeneral } from './FlexibleGeneral'; import { FlexibleSpecial } from './FlexibleSpecial'; import { Island } from './Island'; import { NavList } from './NavList'; +import { PersonalisedMediumFour } from './PersonalisedMediumFour.importable'; import { ScrollableFeature } from './ScrollableFeature.importable'; import { ScrollableHighlights } from './ScrollableHighlights.importable'; import { ScrollableMedium } from './ScrollableMedium.importable'; @@ -48,6 +50,7 @@ type Props = { collectionId: number; containerLevel?: DCRContainerLevel; isInStarRatingVariant?: boolean; + pillarBuckets?: PillarBucket; }; export const DecideContainer = ({ @@ -64,6 +67,7 @@ export const DecideContainer = ({ collectionId, containerLevel, isInStarRatingVariant, + pillarBuckets, }: Props) => { switch (containerType) { case 'dynamic/fast': @@ -298,6 +302,22 @@ export const DecideContainer = ({ ); case 'static/medium/4': + if (pillarBuckets) { + return ( + + + + ); + } return ( { + if (isMediaCard(format) || isNewsletter) { + return 'top'; + } + + return 'bottom'; +}; + +type Props = { + trails: DCRFrontCard[]; + imageLoading: Loading; + containerPalette?: DCRContainerPalette; + showAge?: boolean; + serverTime?: number; + showImage?: boolean; + aspectRatio: AspectRatio; + containerLevel?: DCRContainerLevel; + isInStarRatingVariant?: boolean; + pillarBuckets?: PillarBucket; +}; + +const filterBuckets = ( + pillarBuckets: PillarBucket, + demotedCards: string[], +): DCRPillarCards => { + const filteredPillarBuckets: DCRPillarCards = { + opinion: [], + sport: [], + culture: [], + lifestyle: [], + }; + + for (const pillar of PILLARS) { + filteredPillarBuckets[pillar] = + pillarBuckets[pillar]?.filter( + (card) => !demotedCards.includes(card.url), + ) ?? []; + } + + return filteredPillarBuckets; +}; + +export const PersonalisedMediumFour = ({ + trails, + containerPalette, + showAge, + serverTime, + imageLoading, + showImage = true, + aspectRatio, + containerLevel = 'Primary', + isInStarRatingVariant, + pillarBuckets, +}: Props) => { + const [orderedTrails, setOrderedTrails] = useState( + trails.slice(0, 4), + ); + const [shouldShowCards, setShouldShowCards] = useState(false); + + const [hasTrackedView, setHasTrackedView] = useState(false); + + const abTests = useBetaAB(); + const isInPersonalisationVariant = + abTests?.isUserInTestGroup( + 'fronts-and-curation-personalised-container', + 'variant', + ) ?? false; + + useEffect(() => { + if (isUndefined(pillarBuckets) || !isInPersonalisationVariant) { + setShouldShowCards(true); + return; + } + + const demotedCards = getDemotedState() ?? []; + + if (demotedCards.length === 0) { + setShouldShowCards(true); + return; + } + + const filteredBuckets = filterBuckets(pillarBuckets, demotedCards); + + const curatedTrails = getCuratedList(filteredBuckets); + + if (curatedTrails.length > 0) { + setOrderedTrails(curatedTrails); + } + + setShouldShowCards(true); + }, [trails, pillarBuckets, isInPersonalisationVariant]); + + useEffect(() => { + if (shouldShowCards && !hasTrackedView && isInPersonalisationVariant) { + trackView(orderedTrails); + setHasTrackedView(true); + } + }, [ + orderedTrails, + hasTrackedView, + shouldShowCards, + isInPersonalisationVariant, + ]); + + return ( + <> +
    + {orderedTrails.map((card, cardIndex) => { + return ( +
  • 0} + isVisible={shouldShowCards} + > + +
  • + ); + })} +
+ + ); +}; diff --git a/dotcom-rendering/src/frontend/feFront.ts b/dotcom-rendering/src/frontend/feFront.ts index e4a7cc3df2c..0675232c780 100644 --- a/dotcom-rendering/src/frontend/feFront.ts +++ b/dotcom-rendering/src/frontend/feFront.ts @@ -328,6 +328,7 @@ export type FECollection = { config: FECollectionConfig; hasMore: boolean; targetedTerritory?: Territory; + bucket?: FEFrontCard[]; }; export type FEFrontConfig = { diff --git a/dotcom-rendering/src/layouts/FrontLayout.tsx b/dotcom-rendering/src/layouts/FrontLayout.tsx index af5744c02bb..7cad0c56338 100644 --- a/dotcom-rendering/src/layouts/FrontLayout.tsx +++ b/dotcom-rendering/src/layouts/FrontLayout.tsx @@ -530,6 +530,7 @@ export const FrontLayout = ({ front, NAV }: Props) => { isInStarRatingVariant={ isInStarRatingVariant } + pillarBuckets={collection.bucket} /> diff --git a/dotcom-rendering/src/lib/personalisationHistory.ts b/dotcom-rendering/src/lib/personalisationHistory.ts new file mode 100644 index 00000000000..1e0e1c6ca07 --- /dev/null +++ b/dotcom-rendering/src/lib/personalisationHistory.ts @@ -0,0 +1,126 @@ +import { isObject, storage } from '@guardian/libs'; +import type { DCRFrontCard } from '../types/front'; + +export const DemotedCardsHistoryKey = 'gu.history.demotedCards'; +export const ViewedCardsHistoryKey = 'gu.history.viewedCards'; + +const MAX_VIEW_COUNT = 2; + +type DemotedCardHistory = string[]; +type CardHistory = { cardUrl: string; viewCount: number }; +type ViewedCardHistory = CardHistory[]; + +/** + * + * A card qualifies for demotion if it has been either + * clicked once + * or + * viewed twice + * + * We use these two metrics as a proxy for (positive or negative) user engagement with the card + */ + +const isValidDemotedState = (history: unknown): history is DemotedCardHistory => + Array.isArray(history) && + history.every((cardUrl) => typeof cardUrl === 'string'); + +/* Retrieve the user's demoted card list from local storage */ +export const getDemotedState = (): DemotedCardHistory | undefined => { + try { + const demotedCardHistory = storage.local.get(DemotedCardsHistoryKey); + + if (!isValidDemotedState(demotedCardHistory)) { + throw new Error(`Invalid ${DemotedCardsHistoryKey} value`); + } + + return demotedCardHistory; + } catch (e) { + /* error parsing the string, so remove the key */ + storage.local.remove(DemotedCardsHistoryKey); + return undefined; + } +}; + +const isValidViewState = (history: unknown): history is ViewedCardHistory => + Array.isArray(history) && + history.every( + (viewedCard) => + isObject(viewedCard) && + 'cardUrl' in viewedCard && + 'viewCount' in viewedCard && + typeof viewedCard.cardUrl === 'string' && + typeof viewedCard.viewCount === 'number', + ); + +/* Retrieve the user's viewed card state from local storage */ +export const getViewedState = (): ViewedCardHistory | undefined => { + try { + const ViewedCardState = storage.local.get(ViewedCardsHistoryKey); + + if (!isValidViewState(ViewedCardState)) { + throw new Error(`Invalid ${ViewedCardsHistoryKey} value`); + } + + return ViewedCardState; + } catch (e) { + /* error parsing the string, so remove the key */ + storage.local.remove(ViewedCardsHistoryKey); + return undefined; + } +}; + +export const trackView = (cards: DCRFrontCard[]): void => { + const recentlyViewedCardUrls = cards.map((card) => card.url); + + const viewedCards = getViewedState() ?? []; + + const cardsForDemotion: DemotedCardHistory = []; + + const updatedViewedCards = [...viewedCards]; + + for (const url of recentlyViewedCardUrls) { + const index = updatedViewedCards.findIndex( + (card) => card.cardUrl === url, + ); + + if (index > -1 && updatedViewedCards[index]) { + // Card already exists, increment count by 1 + const incrementedViewCount = + updatedViewedCards[index].viewCount + 1; + + const updatedCard = { + ...updatedViewedCards[index], + viewCount: incrementedViewCount, + }; + + updatedViewedCards[index] = updatedCard; + + if (incrementedViewCount >= MAX_VIEW_COUNT) { + cardsForDemotion.push(updatedCard.cardUrl); + } + } else { + // New card -> add with count 1 + updatedViewedCards.push({ + cardUrl: url, + viewCount: 1, + }); + } + } + + // Persist viewed cards + storage.local.set(ViewedCardsHistoryKey, updatedViewedCards); + + // Persist deduped list of demoted cards + if (cardsForDemotion.length > 0) { + const demotedCards = getDemotedState() ?? []; + const nextDemoted = Array.from( + new Set([...demotedCards, ...cardsForDemotion]), + ); + + storage.local.set(DemotedCardsHistoryKey, nextDemoted); + } +}; +export const trackPersonalisationClick = (url: string): void => { + const demotedCards = getDemotedState() ?? []; + storage.local.set(DemotedCardsHistoryKey, [...demotedCards, url]); +}; diff --git a/dotcom-rendering/src/model/createCollection.ts b/dotcom-rendering/src/model/createCollection.ts new file mode 100644 index 00000000000..44468c8e477 --- /dev/null +++ b/dotcom-rendering/src/model/createCollection.ts @@ -0,0 +1,140 @@ +import type { FECollection, FEFrontCard } from '../frontend/feFront'; +import type { DCRCollectionType, DCRFrontCard } from '../types/front'; +import { enhanceCards } from './enhanceCards'; + +const personalisedCollection: DCRCollectionType = { + id: 'hardcoded-collection', + displayName: 'Across The Guardian', + description: undefined, + collectionType: 'static/medium/4', + href: undefined, + grouped: { + splash: [], + snap: [], + huge: [], + veryBig: [], + big: [], + standard: [], + }, + curated: [], + backfill: [], + treats: [], + config: { + showDateHeader: false, + }, + canShowMore: false, + targetedTerritory: undefined, + aspectRatio: '5:4', +}; + +export type Pillar = 'opinion' | 'sport' | 'culture' | 'lifestyle'; + +export const PILLARS = [ + 'opinion', + 'sport', + 'culture', + 'lifestyle', +] as const satisfies readonly Pillar[]; + +type PillarContainer = { + pillar: Pillar; + containerName: string; +}; +const pillarContainers: PillarContainer[] = [ + //culture + { containerName: 'Culture', pillar: 'culture' }, + { containerName: 'What to watch', pillar: 'culture' }, + { containerName: 'What to listen to', pillar: 'culture' }, + { containerName: 'What to read', pillar: 'culture' }, + { containerName: 'What to play', pillar: 'culture' }, + { containerName: 'What to visit', pillar: 'culture' }, + { containerName: 'More culture', pillar: 'culture' }, + //opinion + { containerName: 'Opinion', pillar: 'opinion' }, + { containerName: 'More opinion', pillar: 'opinion' }, + { containerName: 'Editorials', pillar: 'opinion' }, + //sport + { containerName: 'Sport', pillar: 'sport' }, + { containerName: 'More sport', pillar: 'sport' }, + //lifestyle + { containerName: 'Lifestyle', pillar: 'lifestyle' }, + { containerName: 'The Filter', pillar: 'lifestyle' }, + { containerName: 'Food', pillar: 'lifestyle' }, + { containerName: 'Relationships', pillar: 'lifestyle' }, + { containerName: 'Money & consumer', pillar: 'lifestyle' }, + { containerName: 'Health & fitness', pillar: 'lifestyle' }, + { containerName: 'Fashion & beauty', pillar: 'lifestyle' }, + { containerName: 'Travel', pillar: 'lifestyle' }, + { containerName: 'More Lifestyle', pillar: 'lifestyle' }, +]; + +export type DCRPillarCards = Record; + +const getPillarCards = (collections: FECollection[]): DCRPillarCards => { + const HighlightUrls = collections + .filter((collection) => 'Highlights' === collection.displayName) + .flatMap((collection) => collection.curated) + .map((card) => card.properties.webUrl); + + const pillarCards: Record = { + lifestyle: [], + opinion: [], + sport: [], + culture: [], + }; + + for (const collection of collections) { + const pillarContainer = pillarContainers.find( + (pillar) => pillar.containerName === collection.displayName, + ); + + if (!pillarContainer) continue; + const curatedCards = [...collection.curated].filter( + (card) => !HighlightUrls.includes(card.properties.webUrl), + ); + + pillarCards[pillarContainer.pillar].push(...curatedCards); + } + + return { + lifestyle: enhanceCards(pillarCards.lifestyle, { + cardInTagPage: false, + discussionApiUrl: 'string', + editionId: 'UK', + }), + opinion: enhanceCards(pillarCards.opinion, { + cardInTagPage: false, + discussionApiUrl: 'string', + editionId: 'UK', + }), + sport: enhanceCards(pillarCards.sport, { + cardInTagPage: false, + discussionApiUrl: 'string', + editionId: 'UK', + }), + culture: enhanceCards(pillarCards.culture, { + cardInTagPage: false, + discussionApiUrl: 'string', + editionId: 'UK', + }), + }; +}; + +export const getCuratedList = (buckets: DCRPillarCards): DCRFrontCard[] => { + return PILLARS.map((pillar) => buckets[pillar][0]).filter( + (card): card is DCRFrontCard => card !== undefined, + ); +}; + +export const createFakeCollection = ( + collections: FECollection[], +): DCRCollectionType => { + const pillarCards = getPillarCards(collections); + const curatedList = getCuratedList(pillarCards); + + return { + ...personalisedCollection, + curated: curatedList, + bucket: pillarCards, + }; +}; diff --git a/dotcom-rendering/src/server/handler.front.web.ts b/dotcom-rendering/src/server/handler.front.web.ts index c338f2101f3..b439933c0f4 100644 --- a/dotcom-rendering/src/server/handler.front.web.ts +++ b/dotcom-rendering/src/server/handler.front.web.ts @@ -4,6 +4,7 @@ import type { FEFront } from '../frontend/feFront'; import type { FETagPage } from '../frontend/feTagPage'; import { decideTagPageBranding, pickBrandingForEdition } from '../lib/branding'; import { decideTrail } from '../lib/decideTrail'; +import { createFakeCollection } from '../model/createCollection'; import { enhanceCards } from '../model/enhanceCards'; import { enhanceCollections } from '../model/enhanceCollections'; import { @@ -24,6 +25,41 @@ const enhanceFront = (body: unknown): Front => { const serverTime = Date.now(); + const isInPersonalisedContainerTest = + data.config.serverSideABTests[ + `fronts-and-curation-personalised-container` + ]; + + const personalisedContainer = isInPersonalisedContainerTest + ? createFakeCollection(data.pressedPage.collections) + : undefined; + + const collections = enhanceCollections({ + collections: data.pressedPage.collections, + editionId: data.editionId, + pageId: data.pageId, + onPageDescription: data.pressedPage.frontProperties.onPageDescription, + isOnPaidContentFront: data.config.isPaidContent, + discussionApiUrl: data.config.discussionApiUrl, + frontBranding: pickBrandingForEdition( + data.pressedPage.frontProperties.commercial.editionBrandings, + data.editionId, + ), + }); + + const personalisedContainerPosition = + data.pressedPage.collections.findIndex( + (c) => c.displayName === 'News', + ) + 1; + + const combinedCollections = personalisedContainer + ? [ + ...collections.slice(0, personalisedContainerPosition), + personalisedContainer, + ...collections.slice(personalisedContainerPosition), + ] + : collections; + return { ...data, webTitle: `${ @@ -31,20 +67,7 @@ const enhanceFront = (body: unknown): Front => { } | The Guardian`, pressedPage: { ...data.pressedPage, - collections: enhanceCollections({ - collections: data.pressedPage.collections, - editionId: data.editionId, - pageId: data.pageId, - onPageDescription: - data.pressedPage.frontProperties.onPageDescription, - isOnPaidContentFront: data.config.isPaidContent, - discussionApiUrl: data.config.discussionApiUrl, - frontBranding: pickBrandingForEdition( - data.pressedPage.frontProperties.commercial - .editionBrandings, - data.editionId, - ), - }), + collections: combinedCollections, }, mostViewed: data.mostViewed.map((trail) => decideTrail(trail)), trendingTopics: extractTrendingTopicsFomFront( diff --git a/dotcom-rendering/src/types/front.ts b/dotcom-rendering/src/types/front.ts index 50269bbc989..d024c405cd9 100644 --- a/dotcom-rendering/src/types/front.ts +++ b/dotcom-rendering/src/types/front.ts @@ -117,7 +117,9 @@ export type DCRSnapType = { }; export type AspectRatio = FEAspectRatio; - +export type PillarBucket = { + [key: string]: DCRFrontCard[]; +}; export type DCRCollectionType = { id: string; displayName: string; @@ -144,6 +146,7 @@ export type DCRCollectionType = { collectionBranding?: CollectionBranding; targetedTerritory?: Territory; aspectRatio?: AspectRatio; + bucket?: PillarBucket; }; export type DCRGroupedTrails = {