diff --git a/dotcom-rendering/src/components/FrontSection.stories.tsx b/dotcom-rendering/src/components/FrontSection.stories.tsx
index 7784e1ec7c7..a1ac86f4c55 100644
--- a/dotcom-rendering/src/components/FrontSection.stories.tsx
+++ b/dotcom-rendering/src/components/FrontSection.stories.tsx
@@ -242,57 +242,112 @@ export const TreatsStory = () => {
};
TreatsStory.storyName = 'with treats and date header';
+export const WithEditorialBadge = () => {
+ return (
+
+
+
+ );
+};
+WithEditorialBadge.storyName = 'with editorial badge';
+
/**
- * Note only the first container should show a badge
+ * Use the same logo for each of the stories with branding
*/
-export const MultipleOnAPaidFront = () => {
- const frontBranding = {
- edition: {
- id: 'UK',
- },
- branding: {
- brandingType: {
- name: 'paid-content',
- },
- sponsorName: 'Grounded',
- logo: {
- src: 'https://static.theguardian.com/commercial/sponsor/28/Oct/2020/daa941da-14fd-46cc-85cb-731ce59050ee-Grounded_badging-280x180.png',
- dimensions: {
- width: 140,
- height: 90,
+const logo = {
+ src: 'https://static.theguardian.com/commercial/sponsor/28/Oct/2020/daa941da-14fd-46cc-85cb-731ce59050ee-Grounded_badging-280x180.png',
+ dimensions: {
+ width: 140,
+ height: 90,
+ },
+ link: '/',
+ label: 'Paid for by',
+};
+
+export const WithSponsoredBranding = () => {
+ return (
+
+
+
+ );
+};
+WithSponsoredBranding.storyName = 'with sponsored branding';
+export const WithPaidBranding = () => {
return (
- <>
-
-
-
-
-
-
- >
+
+
+
+ );
+};
+WithPaidBranding.storyName = 'with paid content branding';
+
+export const WithPaidContentForWholeFront = () => {
+ return (
+
+
+
);
};
-MultipleOnAPaidFront.storyName = 'two sections on a paid front';
+WithPaidContentForWholeFront.storyName = 'with paid content for whole front';
export const PageSkinStory = () => {
return (
diff --git a/dotcom-rendering/src/components/FrontSection.tsx b/dotcom-rendering/src/components/FrontSection.tsx
index f46efa80b07..f891aa24281 100644
--- a/dotcom-rendering/src/components/FrontSection.tsx
+++ b/dotcom-rendering/src/components/FrontSection.tsx
@@ -5,25 +5,21 @@ import {
between,
from,
neutral,
- palette,
space,
- textSans,
until,
} from '@guardian/source-foundations';
-import { Hide } from '@guardian/source-react-components';
import { pageSkinContainer } from '../layouts/lib/pageSkin';
import { decideContainerOverrides } from '../lib/decideContainerOverrides';
import type { EditionId } from '../lib/edition';
import { hideAge } from '../lib/hideAge';
-import type { DCRBadgeType } from '../types/badge';
-import type { Branding, EditionBranding } from '../types/branding';
+import type { CollectionBranding } from '../types/branding';
import type { DCRContainerPalette, TreatType } from '../types/front';
import type { DCRFrontPagination } from '../types/tagFront';
import { isAustralianTerritory, type Territory } from '../types/territory';
import { AustralianTerritorySwitcher } from './AustralianTerritorySwitcher.importable';
-import { Badge } from './Badge';
import { ContainerTitle } from './ContainerTitle';
import { FrontPagination } from './FrontPagination';
+import { FrontSectionTitle } from './FrontSectionTitle';
import { Island } from './Island';
import { ShowHideButton } from './ShowHideButton';
import { ShowMore } from './ShowMore.importable';
@@ -70,7 +66,6 @@ type Props = {
editionId?: EditionId;
/** A list of related links that appear in the bottom of the left column on fronts */
treats?: TreatType[];
- badge?: DCRBadgeType;
/** Enable the "Show More" button on this container to allow readers to load more cards */
canShowMore?: boolean;
ajaxUrl?: string;
@@ -79,8 +74,6 @@ type Props = {
pagination?: DCRFrontPagination;
/** Does this front section reside on a "paid for" content front */
isOnPaidContentFront?: boolean;
- /** Denotes the position of this section on the front */
- index?: number;
/** Indicates if the container is targetted to a specific territory */
targetedTerritory?: Territory;
/** Indicates if the page has a page skin advert
@@ -92,8 +85,7 @@ type Props = {
*/
hasPageSkin?: boolean;
discussionApiUrl: string;
- frontBranding?: EditionBranding;
- containerBranding?: Branding;
+ collectionBranding?: CollectionBranding;
};
const width = (columns: number, columnWidth: number, columnGap: number) =>
@@ -347,12 +339,6 @@ const bottomPadding = css`
padding-bottom: ${space[9]}px;
`;
-const titleStyle = css`
- ${until.leftCol} {
- max-width: 74%;
- }
-`;
-
const decideBackgroundColour = (
overrideBackgroundColour: string | undefined,
hasPageSkin: boolean,
@@ -366,44 +352,6 @@ const decideBackgroundColour = (
return undefined;
};
-const labelStyles = css`
- ${textSans.xxsmall()};
- line-height: 1rem;
- color: ${palette.neutral[46]};
- font-weight: bold;
- margin-top: 0.375rem;
- padding-right: 0.625rem;
- padding-bottom: 0.625rem;
- text-align: left;
-`;
-
-const aboutThisLinkStyles = css`
- ${textSans.xxsmall()};
- line-height: 11px;
- color: ${palette.neutral[46]};
- font-weight: normal;
- text-decoration: none;
-`;
-
-const SponsoredBranding = ({
- branding,
-}: {
- branding: Branding | undefined;
-}) => {
- if (!branding) {
- return null;
- }
- const { logo, aboutThisLink } = branding;
- return (
- <>
-
{logo.label}
-
-
- About this content
-
- >
- );
-};
/**
* # Front Container
*
@@ -504,17 +452,14 @@ export const FrontSection = ({
toggleable = false,
treats,
url,
- badge,
canShowMore,
ajaxUrl,
pagination,
isOnPaidContentFront,
- index,
targetedTerritory,
hasPageSkin = false,
- frontBranding,
discussionApiUrl,
- containerBranding,
+ collectionBranding,
}: Props) => {
const overrides =
containerPalette && decideContainerOverrides(containerPalette);
@@ -528,21 +473,6 @@ export const FrontSection = ({
!!pageId &&
!!ajaxUrl;
- const frontHasEditorialOrDesignBadge = !!badge;
-
- // Only show the badge with a "Paid for by" label on the FIRST card of a paid front, aka as Labs front.
- const isTheFirstContainerOnAPaidFront =
- isOnPaidContentFront && index === 0 && !!frontBranding;
-
- const isTheFirstContainerOnASponsoredFront =
- !isOnPaidContentFront && index === 0 && !!frontBranding;
-
- const showSponsoredBranding =
- (isTheFirstContainerOnASponsoredFront && frontBranding.branding) ||
- containerBranding;
-
- const sponsoredBranding = frontBranding?.branding ?? containerBranding;
-
/**
* id is being used to set the containerId in @see {ShowMore.importable.tsx}
* this id pre-existed showMore so is probably also being used for something else.
@@ -590,78 +520,21 @@ export const FrontSection = ({
sectionHeadlineHeight,
]}
>
- {isTheFirstContainerOnAPaidFront ? (
-
+
- {!!frontBranding.branding?.logo.src && (
-
- Paid for by
-
-
- )}
-
- ) : (
- <>
- {frontHasEditorialOrDesignBadge && (
-
-
-
- )}
-
- {frontHasEditorialOrDesignBadge && (
-
-
-
- )}
-
- {showSponsoredBranding && (
-
- )}
-
- >
- )}
+ }
+ collectionBranding={collectionBranding}
+ />
{leftContent}
diff --git a/dotcom-rendering/src/components/FrontSectionTitle.tsx b/dotcom-rendering/src/components/FrontSectionTitle.tsx
new file mode 100644
index 00000000000..4ee9c42e5ee
--- /dev/null
+++ b/dotcom-rendering/src/components/FrontSectionTitle.tsx
@@ -0,0 +1,145 @@
+import { css } from '@emotion/react';
+import { from, palette, textSans, until } from '@guardian/source-foundations';
+import { Hide } from '@guardian/source-react-components';
+import { assertUnreachable } from '../lib/assert-unreachable';
+import type { CollectionBranding } from '../types/branding';
+import { Badge } from './Badge';
+
+type Props = {
+ title: React.ReactNode;
+ collectionBranding: CollectionBranding | undefined;
+};
+
+const titleStyle = css`
+ ${until.leftCol} {
+ max-width: 74%;
+ }
+`;
+
+const labelStyles = css`
+ ${textSans.xxsmall()};
+ line-height: 1rem;
+ color: ${palette.neutral[46]};
+ font-weight: bold;
+ margin-top: 0.375rem;
+ padding-right: 0.625rem;
+ padding-bottom: 0.625rem;
+ text-align: left;
+`;
+
+const aboutThisLinkStyles = css`
+ ${textSans.xxsmall()};
+ line-height: 11px;
+ color: ${palette.neutral[46]};
+ font-weight: normal;
+ text-decoration: none;
+`;
+
+export const FrontSectionTitle = ({ title, collectionBranding }: Props) => {
+ switch (collectionBranding?.kind) {
+ case 'editorial': {
+ const {
+ badge: { imageSrc, href },
+ } = collectionBranding;
+ return (
+ <>
+
+
+
+
+
+
+
+ {title}
+
+ >
+ );
+ }
+ case 'foundation': {
+ const {
+ branding: { logo },
+ } = collectionBranding;
+ return (
+ <>
+
+
+
+
+
+
+
+ {title}
+
+ >
+ );
+ }
+ case 'paid-content': {
+ const {
+ isFrontBranding,
+ branding: { logo },
+ } = collectionBranding;
+
+ if (isFrontBranding) {
+ return (
+
+ {title}
+
+ Paid for by
+
+
+
+ );
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+
+ {title}
+
+ >
+ );
+ }
+ case 'sponsored': {
+ const {
+ branding: { logo, aboutThisLink },
+ } = collectionBranding;
+ return (
+
+ );
+ }
+ case undefined: {
+ return {title}
;
+ }
+ default: {
+ assertUnreachable(collectionBranding);
+ return null;
+ }
+ }
+};
diff --git a/dotcom-rendering/src/layouts/FrontLayout.tsx b/dotcom-rendering/src/layouts/FrontLayout.tsx
index e746f4f7322..4342e7afe58 100644
--- a/dotcom-rendering/src/layouts/FrontLayout.tsx
+++ b/dotcom-rendering/src/layouts/FrontLayout.tsx
@@ -32,6 +32,7 @@ import { StickyBottomBanner } from '../components/StickyBottomBanner.importable'
import { SubNav } from '../components/SubNav.importable';
import { TrendingTopics } from '../components/TrendingTopics';
import { WeatherWrapper } from '../components/WeatherWrapper.importable';
+import { badgeFromBranding } from '../lib/branding';
import { canRenderAds } from '../lib/canRenderAds';
import { getContributionsServiceUrl } from '../lib/contributions';
import { decideContainerOverrides } from '../lib/decideContainerOverrides';
@@ -319,21 +320,16 @@ export const FrontLayout = ({ front, NAV }: Props) => {
} | ${ophanName}`;
const mostPopularTitle = 'Most popular';
- const trailsWithoutBranding = collection.paidContentBadge
- ? trails.map((labTrail) => {
- return {
- ...labTrail,
- branding: undefined,
- };
- })
- : trails;
-
- const frontEditionBranding =
- front.pressedPage.frontProperties.commercial.editionBrandings.find(
- (eB) =>
- eB.edition.id === front.editionId &&
- !!eB.branding,
- );
+ // Remove the branding from each of the cards in a paid content collection
+ const trailsWithoutBranding =
+ collection.collectionBranding?.kind === 'paid-content'
+ ? trails.map((labTrail) => {
+ return {
+ ...labTrail,
+ branding: undefined,
+ };
+ })
+ : trails;
if (collection.collectionType === 'fixed/thrasher') {
return (
@@ -499,7 +495,9 @@ export const FrontLayout = ({ front, NAV }: Props) => {
containerName={collection.collectionType}
canShowMore={collection.canShowMore}
url={collection.href}
- badge={collection.paidContentBadge}
+ badge={badgeFromBranding(
+ collection.collectionBranding,
+ )}
data-print-layout="hide"
hasPageSkin={hasPageSkin}
discussionApiUrl={
@@ -655,7 +653,6 @@ export const FrontLayout = ({ front, NAV }: Props) => {
collection,
hasPageSkin,
)}
- badge={collection.editorialBadge}
sectionId={ophanName}
collectionId={collection.id}
pageId={front.pressedPage.id}
@@ -667,13 +664,11 @@ export const FrontLayout = ({ front, NAV }: Props) => {
canShowMore={collection.canShowMore}
ajaxUrl={front.config.ajaxUrl}
isOnPaidContentFront={isPaidContent}
- index={index}
targetedTerritory={collection.targetedTerritory}
hasPageSkin={hasPageSkin}
- frontBranding={frontEditionBranding}
discussionApiUrl={front.config.discussionApiUrl}
- containerBranding={
- collection.sponsoredContentBranding
+ collectionBranding={
+ collection.collectionBranding
}
>
{
+ throw new Error('This should be unreachable');
+};
diff --git a/dotcom-rendering/src/lib/branding.test.ts b/dotcom-rendering/src/lib/branding.test.ts
new file mode 100644
index 00000000000..fbc896690fe
--- /dev/null
+++ b/dotcom-rendering/src/lib/branding.test.ts
@@ -0,0 +1,662 @@
+import type { Branding } from '../types/branding';
+import { decideCollectionBranding } from './branding';
+
+// For the purpose of these tests we don't care about the contents of the logo objects
+const logo = {} as Branding['logo'];
+
+describe('decideCollectionBranding', () => {
+ it('use editorial badge (even with valid branding on cards) if series is defined', () => {
+ const cardBranding = {
+ brandingType: { name: 'paid-content' as const },
+ sponsorName: 'foo',
+ aboutThisLink: '',
+ logo,
+ };
+ const collectionBranding = decideCollectionBranding({
+ frontBranding: undefined,
+ couldDisplayFrontBranding: false,
+ seriesTag: 'politics/series/road-to-the-vote',
+ cards: [
+ {
+ properties: {
+ editionBrandings: [
+ {
+ edition: { id: 'UK' },
+ branding: cardBranding,
+ },
+ ],
+ },
+ },
+ {
+ properties: {
+ editionBrandings: [
+ {
+ edition: { id: 'UK' },
+ branding: cardBranding,
+ },
+ ],
+ },
+ },
+ {
+ properties: {
+ editionBrandings: [
+ {
+ edition: { id: 'UK' },
+ branding: cardBranding,
+ },
+ ],
+ },
+ },
+ ],
+ editionId: 'UK',
+ });
+ expect(collectionBranding).toStrictEqual({
+ kind: 'editorial',
+ badge: {
+ imageSrc: `/static/frontend/badges/EUReferendumBadge.svg`,
+ href: `/politics/series/road-to-the-vote`,
+ },
+ });
+ });
+
+ it('picks branding from a card by their edition', () => {
+ const cards = [
+ {
+ properties: {
+ editionBrandings: [
+ {
+ edition: { id: 'UK' as const },
+ branding: {
+ brandingType: { name: 'paid-content' as const },
+ sponsorName: 'foo',
+ aboutThisLink: '',
+ logo,
+ },
+ },
+ {
+ edition: { id: 'US' as const },
+ branding: {
+ brandingType: { name: 'sponsored' as const },
+ sponsorName: 'bar',
+ aboutThisLink: '',
+ logo,
+ },
+ },
+ ],
+ },
+ },
+ ];
+ const ukBranding = decideCollectionBranding({
+ frontBranding: undefined,
+ couldDisplayFrontBranding: false,
+ seriesTag: undefined,
+ cards,
+ editionId: 'UK',
+ });
+ expect(ukBranding).toMatchObject({
+ kind: 'paid-content',
+ isFrontBranding: false,
+ branding: {
+ brandingType: { name: 'paid-content' },
+ sponsorName: 'foo',
+ aboutThisLink: '',
+ logo,
+ },
+ });
+ const usBranding = decideCollectionBranding({
+ frontBranding: undefined,
+ couldDisplayFrontBranding: false,
+ seriesTag: undefined,
+ cards,
+ editionId: 'US',
+ });
+ expect(usBranding).toMatchObject({
+ kind: 'sponsored',
+ isFrontBranding: false,
+ branding: {
+ brandingType: { name: 'sponsored' },
+ sponsorName: 'bar',
+ aboutThisLink: '',
+ logo,
+ },
+ });
+ });
+
+ it('is paid content derived from multiple cards', () => {
+ const cardBranding = {
+ brandingType: { name: 'paid-content' as const },
+ sponsorName: 'foo',
+ aboutThisLink: '',
+ logo,
+ };
+ const collectionBranding = decideCollectionBranding({
+ frontBranding: undefined,
+ couldDisplayFrontBranding: false,
+ seriesTag: undefined,
+ cards: [
+ {
+ properties: {
+ editionBrandings: [
+ {
+ edition: { id: 'UK' },
+ branding: cardBranding,
+ },
+ ],
+ },
+ },
+ {
+ properties: {
+ editionBrandings: [
+ {
+ edition: { id: 'UK' },
+ branding: cardBranding,
+ },
+ ],
+ },
+ },
+ {
+ properties: {
+ editionBrandings: [
+ {
+ edition: { id: 'UK' },
+ branding: cardBranding,
+ },
+ ],
+ },
+ },
+ ],
+ editionId: 'UK',
+ });
+ expect(collectionBranding).toMatchObject({
+ kind: 'paid-content',
+ isFrontBranding: false,
+ branding: cardBranding,
+ });
+ });
+
+ it('undefined when not all cards have branding', () => {
+ // The branding we'll apply to each card in this test
+ const collectionBranding = decideCollectionBranding({
+ frontBranding: undefined,
+ couldDisplayFrontBranding: false,
+ seriesTag: undefined,
+ cards: [
+ {
+ properties: {
+ editionBrandings: [
+ {
+ edition: { id: 'UK' },
+ branding: {
+ brandingType: { name: 'paid-content' },
+ sponsorName: 'foo',
+ aboutThisLink: '',
+ logo,
+ },
+ },
+ ],
+ },
+ },
+ {
+ properties: {
+ editionBrandings: [
+ {
+ edition: { id: 'UK' },
+ branding: {
+ brandingType: { name: 'paid-content' },
+ sponsorName: 'bar',
+ aboutThisLink: '',
+ logo,
+ },
+ },
+ ],
+ },
+ },
+ {
+ properties: {
+ editionBrandings: [],
+ },
+ },
+ ],
+ editionId: 'UK',
+ });
+ expect(collectionBranding).toBeUndefined();
+ });
+
+ it('is undefined when no cards have branding', () => {
+ const collectionBranding = decideCollectionBranding({
+ frontBranding: undefined,
+ couldDisplayFrontBranding: false,
+ seriesTag: undefined,
+ cards: [
+ {
+ properties: {
+ editionBrandings: [],
+ },
+ },
+ {
+ properties: {
+ editionBrandings: [],
+ },
+ },
+ {
+ properties: {
+ editionBrandings: [],
+ },
+ },
+ ],
+ editionId: 'UK',
+ });
+ expect(collectionBranding).toBeUndefined();
+ });
+
+ it('is undefined when cards have different branding types', () => {
+ const collectionBranding = decideCollectionBranding({
+ frontBranding: undefined,
+ couldDisplayFrontBranding: false,
+ seriesTag: undefined,
+ cards: [
+ {
+ properties: {
+ editionBrandings: [
+ {
+ edition: { id: 'UK' },
+ branding: {
+ brandingType: { name: 'paid-content' },
+ sponsorName: 'foo',
+ aboutThisLink: '',
+ logo,
+ },
+ },
+ ],
+ },
+ },
+ {
+ properties: {
+ editionBrandings: [
+ {
+ edition: { id: 'UK' },
+ branding: {
+ brandingType: { name: 'sponsored' },
+ sponsorName: 'bar',
+ aboutThisLink: '',
+ logo,
+ },
+ },
+ ],
+ },
+ },
+ {
+ properties: {
+ editionBrandings: [
+ {
+ edition: { id: 'UK' },
+ branding: {
+ brandingType: { name: 'foundation' },
+ sponsorName: 'baz',
+ aboutThisLink: '',
+ logo,
+ },
+ },
+ ],
+ },
+ },
+ ],
+ editionId: 'UK',
+ });
+ expect(collectionBranding).toBeUndefined();
+ });
+
+ it('is sponsored branding when all of the branding types are sponsored and the names match', () => {
+ const cardBranding = {
+ brandingType: { name: 'sponsored' as const },
+ sponsorName: 'foo',
+ aboutThisLink: '',
+ logo,
+ };
+ const collectionBranding = decideCollectionBranding({
+ frontBranding: undefined,
+ couldDisplayFrontBranding: false,
+ seriesTag: undefined,
+ cards: [
+ {
+ properties: {
+ editionBrandings: [
+ {
+ edition: { id: 'UK' },
+ branding: cardBranding,
+ },
+ ],
+ },
+ },
+ {
+ properties: {
+ editionBrandings: [
+ {
+ edition: { id: 'UK' },
+ branding: cardBranding,
+ },
+ ],
+ },
+ },
+ {
+ properties: {
+ editionBrandings: [
+ {
+ edition: { id: 'UK' },
+ branding: cardBranding,
+ },
+ ],
+ },
+ },
+ ],
+ editionId: 'UK',
+ });
+ expect(collectionBranding).toStrictEqual({
+ kind: 'sponsored',
+ isFrontBranding: false,
+ branding: cardBranding,
+ });
+ });
+
+ it('is undefined when branding cards are sponsored and have different sponsor names', () => {
+ // The branding we'll apply to each card in this test
+ const collectionBranding = decideCollectionBranding({
+ frontBranding: undefined,
+ couldDisplayFrontBranding: false,
+ seriesTag: undefined,
+ cards: [
+ {
+ properties: {
+ editionBrandings: [
+ {
+ edition: { id: 'UK' },
+ branding: {
+ brandingType: { name: 'sponsored' },
+ sponsorName: 'foo',
+ aboutThisLink: '',
+ logo,
+ },
+ },
+ ],
+ },
+ },
+ {
+ properties: {
+ editionBrandings: [
+ {
+ edition: { id: 'UK' },
+ branding: {
+ brandingType: { name: 'sponsored' },
+ sponsorName: 'bar',
+ aboutThisLink: '',
+ logo,
+ },
+ },
+ ],
+ },
+ },
+ {
+ properties: {
+ editionBrandings: [
+ {
+ edition: { id: 'UK' },
+ branding: {
+ brandingType: { name: 'sponsored' },
+ sponsorName: 'baz',
+ aboutThisLink: '',
+ logo,
+ },
+ },
+ ],
+ },
+ },
+ ],
+ editionId: 'UK',
+ });
+ expect(collectionBranding).toBeUndefined();
+ });
+
+ it('is paid content branding when all of the branding types are paid-content and the names match', () => {
+ const collectionBranding = decideCollectionBranding({
+ frontBranding: undefined,
+ couldDisplayFrontBranding: false,
+ seriesTag: undefined,
+ cards: [
+ {
+ properties: {
+ editionBrandings: [
+ {
+ edition: { id: 'UK' },
+ branding: {
+ brandingType: { name: 'paid-content' },
+ sponsorName: 'foo',
+ aboutThisLink: '',
+ logo,
+ },
+ },
+ ],
+ },
+ },
+ {
+ properties: {
+ editionBrandings: [
+ {
+ edition: { id: 'UK' },
+ branding: {
+ brandingType: { name: 'paid-content' },
+ sponsorName: 'foo',
+ aboutThisLink: '',
+ logo,
+ },
+ },
+ ],
+ },
+ },
+ ],
+ editionId: 'UK',
+ });
+ expect(collectionBranding).toStrictEqual({
+ kind: 'paid-content',
+ isFrontBranding: false,
+ branding: {
+ brandingType: { name: 'paid-content' },
+ sponsorName: 'foo',
+ aboutThisLink: '',
+ logo,
+ },
+ });
+ });
+
+ it('is undefined when branding cards are paid-content and have different sponsor names', () => {
+ const collectionBranding = decideCollectionBranding({
+ frontBranding: undefined,
+ couldDisplayFrontBranding: false,
+ seriesTag: undefined,
+ cards: [
+ {
+ properties: {
+ editionBrandings: [
+ {
+ edition: { id: 'UK' },
+ branding: {
+ brandingType: { name: 'paid-content' },
+ sponsorName: 'foo',
+ aboutThisLink: '',
+ logo,
+ },
+ },
+ ],
+ },
+ },
+ {
+ properties: {
+ editionBrandings: [
+ {
+ edition: { id: 'UK' },
+ branding: {
+ brandingType: { name: 'paid-content' },
+ sponsorName: 'bar',
+ aboutThisLink: '',
+ logo,
+ },
+ },
+ ],
+ },
+ },
+ ],
+ editionId: 'UK',
+ });
+ expect(collectionBranding).toBeUndefined();
+ });
+
+ it('is front branding when present and possible to display', () => {
+ const collectionBranding = decideCollectionBranding({
+ frontBranding: {
+ brandingType: { name: 'paid-content' },
+ sponsorName: 'bar',
+ aboutThisLink: '',
+ logo,
+ },
+ couldDisplayFrontBranding: true,
+ seriesTag: undefined,
+ cards: [],
+ editionId: 'UK',
+ });
+ expect(collectionBranding).toStrictEqual({
+ kind: 'paid-content',
+ isFrontBranding: true,
+ branding: {
+ brandingType: { name: 'paid-content' },
+ sponsorName: 'bar',
+ aboutThisLink: '',
+ logo,
+ },
+ });
+ });
+
+ it('is undefined when there is front branding (and no card branding) that is not eligible for display on this collection', () => {
+ const collectionBranding = decideCollectionBranding({
+ frontBranding: {
+ brandingType: { name: 'paid-content' },
+ sponsorName: 'bar',
+ aboutThisLink: '',
+ logo,
+ },
+ couldDisplayFrontBranding: false,
+ seriesTag: undefined,
+ cards: [],
+ editionId: 'UK',
+ });
+ expect(collectionBranding).toBeUndefined();
+ });
+
+ it('when cards are present', () => {
+ const cardBranding = {
+ brandingType: { name: 'paid-content' as const },
+ sponsorName: 'foo',
+ aboutThisLink: '',
+ logo,
+ };
+ const collectionBranding = decideCollectionBranding({
+ frontBranding: {
+ brandingType: { name: 'sponsored' },
+ sponsorName: 'bar',
+ aboutThisLink: '',
+ logo,
+ },
+ couldDisplayFrontBranding: false,
+ seriesTag: undefined,
+ cards: [
+ {
+ properties: {
+ editionBrandings: [
+ {
+ edition: { id: 'UK' },
+ branding: cardBranding,
+ },
+ ],
+ },
+ },
+ {
+ properties: {
+ editionBrandings: [
+ {
+ edition: { id: 'UK' },
+ branding: cardBranding,
+ },
+ ],
+ },
+ },
+ {
+ properties: {
+ editionBrandings: [
+ {
+ edition: { id: 'UK' },
+ branding: cardBranding,
+ },
+ ],
+ },
+ },
+ ],
+ editionId: 'UK',
+ });
+ expect(collectionBranding).toStrictEqual({
+ kind: 'paid-content',
+ isFrontBranding: false,
+ branding: cardBranding,
+ });
+ });
+
+ it('is undefined when front branding matches card branding, but we are not displaying front branding', () => {
+ const cardBranding = {
+ brandingType: { name: 'paid-content' as const },
+ sponsorName: 'foo',
+ aboutThisLink: '',
+ logo,
+ };
+ const collectionBranding = decideCollectionBranding({
+ frontBranding: {
+ brandingType: { name: 'paid-content' },
+ sponsorName: 'foo',
+ aboutThisLink: '',
+ logo,
+ },
+ couldDisplayFrontBranding: false,
+ seriesTag: undefined,
+ cards: [
+ {
+ properties: {
+ editionBrandings: [
+ {
+ edition: { id: 'UK' },
+ branding: cardBranding,
+ },
+ ],
+ },
+ },
+ {
+ properties: {
+ editionBrandings: [
+ {
+ edition: { id: 'UK' },
+ branding: cardBranding,
+ },
+ ],
+ },
+ },
+ {
+ properties: {
+ editionBrandings: [
+ {
+ edition: { id: 'UK' },
+ branding: cardBranding,
+ },
+ ],
+ },
+ },
+ ],
+ editionId: 'UK',
+ });
+ expect(collectionBranding).toBeUndefined();
+ });
+});
diff --git a/dotcom-rendering/src/lib/branding.ts b/dotcom-rendering/src/lib/branding.ts
index b7cbcb0a294..a84c4f7cf0b 100644
--- a/dotcom-rendering/src/lib/branding.ts
+++ b/dotcom-rendering/src/lib/branding.ts
@@ -1,5 +1,21 @@
-import type { Branding, EditionBranding } from '../types/branding';
+import { decideEditorialBadge } from '../model/decideEditorialBadge';
+import type { DCRBadgeType } from '../types/badge';
+import type {
+ Branding,
+ BrandingType,
+ CollectionBranding,
+ EditionBranding,
+} from '../types/branding';
+import { assertUnreachable } from './assert-unreachable';
import type { EditionId } from './edition';
+import type { NonEmptyArray } from './tuple';
+import { isNonEmptyArray } from './tuple';
+
+/**
+ * For the sake of determining branding on a collection, these are the only
+ * properties we care about for any given card
+ */
+type CardWithBranding = { properties: { editionBrandings: EditionBranding[] } };
export const pickBrandingForEdition = (
editionBrandings: EditionBranding[],
@@ -8,3 +24,170 @@ export const pickBrandingForEdition = (
editionBrandings.find(
({ edition, branding }) => edition.id === editionId && branding,
)?.branding;
+
+/**
+ * Retrieve the branding object from each of the cards in an array of cards, for a given edition
+ *
+ * @returns `undefined` if AT LEAST ONE of the cards is missing branding,
+ * otherwise returns a non-empty array of branding
+ */
+const getBrandingFromCards = (
+ cards: CardWithBranding[],
+ editionId: EditionId,
+): NonEmptyArray | undefined => {
+ const brandings: Branding[] = [];
+ for (const card of cards) {
+ const branding = pickBrandingForEdition(
+ card.properties.editionBrandings,
+ editionId,
+ );
+ // If a single card is missing branding then we bail out immediately
+ if (!branding) {
+ return undefined;
+ }
+ brandings.push(branding);
+ }
+ if (!isNonEmptyArray(brandings)) {
+ return undefined;
+ }
+ return brandings;
+};
+
+const getBrandingType = ([
+ firstBranding,
+ ...restBranding
+]: NonEmptyArray): BrandingType['name'] | undefined => {
+ const name = firstBranding.brandingType?.name;
+
+ if (!name) {
+ return undefined;
+ }
+
+ const allNamesMatch = restBranding.every(
+ ({ brandingType }) => brandingType?.name === name,
+ );
+
+ if (!allNamesMatch) {
+ return undefined;
+ }
+
+ return name;
+};
+
+/**
+ * Check each branding has the same sponsor name
+ */
+const everyCardHasSameSponsor = ([
+ firstBranding,
+ ...restBranding
+]: NonEmptyArray): boolean =>
+ restBranding.every(
+ (branding) => branding.sponsorName === firstBranding.sponsorName,
+ );
+
+/**
+ * TODO verify that this is what is necessary for two branding to be equal
+ */
+const brandingEqual = (b1: Branding, b2: Branding) => {
+ return (
+ b1.brandingType?.name === b2.brandingType?.name &&
+ b1.sponsorName === b2.sponsorName
+ );
+};
+
+export const badgeFromBranding = (
+ collectionBranding: CollectionBranding | undefined,
+): DCRBadgeType | undefined => {
+ switch (collectionBranding?.kind) {
+ case 'paid-content':
+ case 'sponsored':
+ case 'foundation': {
+ const { logo } = collectionBranding.branding;
+ return {
+ imageSrc: logo.src,
+ href: logo.link,
+ };
+ }
+ case 'editorial': {
+ return collectionBranding.badge;
+ }
+ case undefined: {
+ return undefined;
+ }
+ default: {
+ return assertUnreachable(collectionBranding);
+ }
+ }
+};
+
+export const decideCollectionBranding = ({
+ frontBranding,
+ couldDisplayFrontBranding,
+ seriesTag,
+ cards,
+ editionId,
+}: {
+ frontBranding: Branding | undefined;
+ couldDisplayFrontBranding: boolean;
+ seriesTag: string | undefined;
+ cards: CardWithBranding[];
+ editionId: EditionId;
+}): CollectionBranding | undefined => {
+ // If this collection is eligible to display front branding
+ // AND there is front branding defined, we should display it
+ if (couldDisplayFrontBranding && frontBranding !== undefined) {
+ const kind = getBrandingType([frontBranding]);
+ if (!kind) {
+ return undefined;
+ }
+ return {
+ kind,
+ isFrontBranding: true,
+ branding: frontBranding,
+ };
+ }
+
+ // If the series tag of this collection matches an editorial badge, we should use that
+ const editorialBadge = decideEditorialBadge(seriesTag);
+ if (editorialBadge) {
+ return {
+ kind: 'editorial',
+ badge: editorialBadge,
+ };
+ }
+
+ // Retrieve an array of branding from the cards that belong to the collection
+ // If this is valid (aka not undefined), then we can use it to derive the
+ // branding of the collection
+ const brandingForCards = getBrandingFromCards(cards, editionId);
+ if (!brandingForCards) {
+ return undefined;
+ }
+
+ const kind = getBrandingType(brandingForCards);
+ if (!kind) {
+ return undefined;
+ }
+
+ const [branding] = brandingForCards;
+
+ // If this collection belongs to a front that has branding, and the branding
+ // derived from the cards is the same, then don't display this branding.
+ // This takes care of the case when another card is displaying the branding
+ // on behalf of the whole front and this collection is further down the
+ // front, with its branding hidden
+ if (frontBranding !== undefined && brandingEqual(frontBranding, branding)) {
+ return undefined;
+ }
+
+ // Ensure each of the card's branding has the same sponsor
+ if (!everyCardHasSameSponsor(brandingForCards)) {
+ return undefined;
+ }
+
+ return {
+ kind,
+ isFrontBranding: false,
+ branding,
+ };
+};
diff --git a/dotcom-rendering/src/lib/tuple.test.ts b/dotcom-rendering/src/lib/tuple.test.ts
index 8751a04161c..33b398afb3d 100644
--- a/dotcom-rendering/src/lib/tuple.test.ts
+++ b/dotcom-rendering/src/lib/tuple.test.ts
@@ -1,4 +1,4 @@
-import { takeFirst } from './tuple';
+import { isNonEmptyArray, takeFirst } from './tuple';
describe('takeFirst', () => {
it('Always returns the correct array length when the array is one less, the same as, or one more than n', () => {
@@ -81,3 +81,9 @@ describe('takeFirst', () => {
expect(results[35].length).toEqual(11);
});
});
+
+it('isNonEmptyArray', () => {
+ expect(isNonEmptyArray([])).toBe(false);
+ expect(isNonEmptyArray([1])).toBe(true);
+ expect(isNonEmptyArray([1, 2, 3])).toBe(true);
+});
diff --git a/dotcom-rendering/src/lib/tuple.ts b/dotcom-rendering/src/lib/tuple.ts
index 0aa18c0de6d..f46260e8757 100644
--- a/dotcom-rendering/src/lib/tuple.ts
+++ b/dotcom-rendering/src/lib/tuple.ts
@@ -87,3 +87,9 @@ export const takeFirst = <
* Type representing an array with at least one element
*/
export type NonEmptyArray = [T, ...T[]];
+
+/**
+ * Type guard for determining whether an array has at least one element
+ */
+export const isNonEmptyArray = (items: T[]): items is NonEmptyArray =>
+ items.length > 0;
diff --git a/dotcom-rendering/src/model/decideBadge.test.ts b/dotcom-rendering/src/model/decideBadge.test.ts
deleted file mode 100644
index 5d7de341863..00000000000
--- a/dotcom-rendering/src/model/decideBadge.test.ts
+++ /dev/null
@@ -1,117 +0,0 @@
-import { decideEditorialBadge, decidePaidContentBadge } from './decideBadge';
-
-jest.mock('./badges');
-
-const brandingAmazon = {
- brandingType: {
- name: 'paid-content',
- },
- sponsorName: 'Amazon',
- logo: {
- src: 'https://static.theguardian.com/commercial/sponsor/04/Oct/2018/6b15ba78-da66-415d-8540-a34cc4d3156b-romanoffs_TT_PO-center.png',
- dimensions: {
- width: 140,
- height: 90,
- },
- link: 'https://www.amazon.com/dp/B07FV6K8HF',
- label: 'Paid for by',
- },
- aboutThisLink:
- 'https://www.theguardian.com/info/2016/jan/25/content-funding',
-} as const;
-
-const brandingGuardianOrg = {
- brandingType: {
- name: 'sponsored',
- },
- sponsorName: 'guardian.org',
- logo: {
- src: 'https://static.theguardian.com/commercial/sponsor/16/Mar/2022/1e17c6f8-8114-44e9-8e10-02b8e5c6b929-theguardianorg badge.png',
- dimensions: {
- width: 280,
- height: 180,
- },
- link: 'https://www.theguardian.com/global-development/2021/feb/21/about-the-rights-and-freedom-series',
- label: 'Supported by',
- },
- logoForDarkBackground: {
- src: 'https://static.theguardian.com/commercial/sponsor/16/Mar/2022/2cb64c63-e09c-4877-90ee-b5fe4eac7fc6-44d00539-51bd-4749-a16a-7dde8ff8e19b-a060074a-c6d4-4e6e-b33a-8f4930d5617c-g.org_hc.png',
- dimensions: {
- width: 280,
- height: 180,
- },
- link: 'https://www.theguardian.com/global-development/2021/feb/21/about-the-rights-and-freedom-series',
- label: 'Supported by',
- },
- aboutThisLink:
- 'https://www.theguardian.com/global-development/2021/feb/21/about-the-rights-and-freedom-series',
-} as const;
-
-describe('Decide badge', () => {
- describe('getBadgeFromSeriesTag', () => {
- it('returns correct standard badge', () => {
- const tagId = 'uk-news/series/the-brexit-gamble';
- const expectedResult = {
- href: `/${tagId}`,
- imageSrc: `/static/frontend/badges/EUReferendumBadge.svg`,
- };
- const result = decideEditorialBadge(tagId);
- expect(result).toMatchObject(expectedResult);
- });
-
- it('returns correct special badge', () => {
- const tagId = 'tone/newsletter-tone';
- const expectedResult = {
- href: `/${tagId}`,
- imageSrc: `/static/frontend/badges/newsletter-badge.svg`,
- };
- const result = decideEditorialBadge(tagId);
- expect(result).toMatchObject(expectedResult);
- });
-
- it('returns undefined if no standard or special badge match found for series tag', () => {
- const tagId = 'lifeandstyle/home-and-garden';
- const expectedResult = undefined;
- const result = decideEditorialBadge(tagId);
- expect(result).toEqual(expectedResult);
- });
-
- it('returns undefined for undefined series tag', () => {
- const tagId = undefined;
- const expectedResult = undefined;
- const result = decideEditorialBadge(tagId);
- expect(result).toEqual(expectedResult);
- });
- });
-
- describe('getBadgeFromBranding function', () => {
- it('returns properties of the first badge if all cards have the same sponsor', () => {
- const branding = [brandingAmazon, brandingAmazon];
-
- const expectedResult = {
- imageSrc: brandingAmazon.logo.src,
- href: brandingAmazon.logo.link,
- };
- const result = decidePaidContentBadge(branding);
- expect(result).toEqual(expectedResult);
- });
-
- it('returns undefined if all cards do not have the same sponsor', () => {
- const branding = [brandingAmazon, brandingGuardianOrg];
-
- const expectedResult = undefined;
- const result = decidePaidContentBadge(branding);
- expect(result).toEqual(expectedResult);
- });
-
- it('returns undefined if no branding supplied', () => {
- const expectedResult = undefined;
-
- const result = decidePaidContentBadge(undefined);
- expect(result).toEqual(expectedResult);
-
- const result2 = decidePaidContentBadge([]);
- expect(result2).toEqual(expectedResult);
- });
- });
-});
diff --git a/dotcom-rendering/src/model/decideBadge.ts b/dotcom-rendering/src/model/decideBadge.ts
deleted file mode 100644
index e82eec222a3..00000000000
--- a/dotcom-rendering/src/model/decideBadge.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { createHash } from 'node:crypto';
-import { ASSET_ORIGIN } from '../lib/assets';
-import type { DCRBadgeType } from '../types/badge';
-import type { Branding } from '../types/branding';
-import { BADGES, SPECIAL_BADGES } from './badges';
-
-/**
- * Fetches the badge properties only if ALL branding has the same sponsor.
- */
-export const decidePaidContentBadge = (
- branding?: Branding[],
-): DCRBadgeType | undefined => {
- // Early return if there are no branding elements
- if (!branding) return;
- if (!branding.length) return;
-
- const [firstBrand] = branding;
- // Early return if the first brand is falsy
- if (!firstBrand) return;
-
- const allBrandingHasSameSponsor = branding.every(
- ({ sponsorName }) => sponsorName === firstBrand.sponsorName,
- );
-
- return allBrandingHasSameSponsor
- ? {
- imageSrc: firstBrand.logo.src,
- href: firstBrand.logo.link,
- }
- : undefined;
-};
-
-/**
- * Fetches the corresponding badge using the series tag, if there's a match in the lookup.
- */
-export const decideEditorialBadge = (
- seriesTag?: string,
-): DCRBadgeType | undefined => {
- if (!seriesTag) return undefined;
-
- const badge = BADGES.find((b) => b.seriesTag === seriesTag);
- if (badge) {
- return {
- imageSrc: `${ASSET_ORIGIN}static/frontend/${badge.imageSrc}`,
- href: `/${seriesTag}`,
- };
- } else {
- // "Special" hidden badges have their series tags hashed
- const specialBadge = SPECIAL_BADGES.find((b) => {
- const badgeHash = createHash('md5')
- .update(b.salt + seriesTag)
- .digest('hex');
- return badgeHash.includes(b.hashedTag);
- });
-
- return specialBadge
- ? {
- imageSrc: `${ASSET_ORIGIN}static/frontend/${specialBadge.imageSrc}`,
- href: `/${seriesTag}`,
- }
- : undefined; // No badge or special badge found
- }
-};
diff --git a/dotcom-rendering/src/model/decideEditorialBadge.test.ts b/dotcom-rendering/src/model/decideEditorialBadge.test.ts
new file mode 100644
index 00000000000..02b67d82d7d
--- /dev/null
+++ b/dotcom-rendering/src/model/decideEditorialBadge.test.ts
@@ -0,0 +1,39 @@
+import { decideEditorialBadge } from './decideEditorialBadge';
+
+jest.mock('./badges');
+
+describe('decideEditorialBadge', () => {
+ it('returns correct standard badge', () => {
+ const tagId = 'uk-news/series/the-brexit-gamble';
+ const expectedResult = {
+ href: `/${tagId}`,
+ imageSrc: `/static/frontend/badges/EUReferendumBadge.svg`,
+ };
+ const result = decideEditorialBadge(tagId);
+ expect(result).toMatchObject(expectedResult);
+ });
+
+ it('returns correct special badge', () => {
+ const tagId = 'tone/newsletter-tone';
+ const expectedResult = {
+ href: `/${tagId}`,
+ imageSrc: `/static/frontend/badges/newsletter-badge.svg`,
+ };
+ const result = decideEditorialBadge(tagId);
+ expect(result).toMatchObject(expectedResult);
+ });
+
+ it('returns undefined if no standard or special badge match found for series tag', () => {
+ const tagId = 'lifeandstyle/home-and-garden';
+ const expectedResult = undefined;
+ const result = decideEditorialBadge(tagId);
+ expect(result).toEqual(expectedResult);
+ });
+
+ it('returns undefined for undefined series tag', () => {
+ const tagId = undefined;
+ const expectedResult = undefined;
+ const result = decideEditorialBadge(tagId);
+ expect(result).toEqual(expectedResult);
+ });
+});
diff --git a/dotcom-rendering/src/model/decideEditorialBadge.ts b/dotcom-rendering/src/model/decideEditorialBadge.ts
new file mode 100644
index 00000000000..f6cdee0dee6
--- /dev/null
+++ b/dotcom-rendering/src/model/decideEditorialBadge.ts
@@ -0,0 +1,39 @@
+import { createHash } from 'node:crypto';
+import { ASSET_ORIGIN } from '../lib/assets';
+import type { DCRBadgeType } from '../types/badge';
+import { BADGES, SPECIAL_BADGES } from './badges';
+
+/**
+ * Fetches the corresponding badge using the series tag, if there's a match in the lookup.
+ */
+export const decideEditorialBadge = (
+ seriesTag?: string,
+): DCRBadgeType | undefined => {
+ if (!seriesTag) return undefined;
+
+ const badge = BADGES.find((b) => b.seriesTag === seriesTag);
+ if (badge) {
+ return {
+ imageSrc: `${ASSET_ORIGIN}static/frontend/${badge.imageSrc}`,
+ href: `/${seriesTag}`,
+ };
+ }
+
+ // "Special" hidden badges have their series tags hashed
+ const specialBadge = SPECIAL_BADGES.find((b) => {
+ const badgeHash = createHash('md5')
+ .update(b.salt + seriesTag)
+ .digest('hex');
+ return badgeHash.includes(b.hashedTag);
+ });
+
+ if (specialBadge) {
+ return {
+ imageSrc: `${ASSET_ORIGIN}static/frontend/${specialBadge.imageSrc}`,
+ href: `/${seriesTag}`,
+ };
+ }
+
+ // No badge or special badge found
+ return undefined;
+};
diff --git a/dotcom-rendering/src/model/decideSponsoredContentBranding.test.ts b/dotcom-rendering/src/model/decideSponsoredContentBranding.test.ts
deleted file mode 100644
index 843740a29f3..00000000000
--- a/dotcom-rendering/src/model/decideSponsoredContentBranding.test.ts
+++ /dev/null
@@ -1,120 +0,0 @@
-import type { Branding } from '../types/branding';
-import { decideSponsoredContentBranding } from './decideSponsoredContentBranding';
-
-const editionBrandingWithSponsor: Branding = {
- brandingType: {
- name: 'sponsored',
- },
- sponsorName: 'guardian.org',
- logo: {
- src: 'https://static.theguardian.com/commercial/sponsor/16/Mar/2022/1e17c6f8-8114-44e9-8e10-02b8e5c6b929-theguardianorg badge.png',
- dimensions: {
- width: 280,
- height: 180,
- },
- link: 'https://www.theguardian.com/global-development/2021/feb/21/about-the-rights-and-freedom-series',
- label: 'Supported by',
- },
- logoForDarkBackground: {
- src: 'https://static.theguardian.com/commercial/sponsor/16/Mar/2022/2cb64c63-e09c-4877-90ee-b5fe4eac7fc6-44d00539-51bd-4749-a16a-7dde8ff8e19b-a060074a-c6d4-4e6e-b33a-8f4930d5617c-g.org_hc.png',
- dimensions: {
- width: 280,
- height: 180,
- },
- link: 'https://www.theguardian.com/global-development/2021/feb/21/about-the-rights-and-freedom-series',
- label: 'Supported by',
- },
- aboutThisLink:
- 'https://www.theguardian.com/global-development/2021/feb/21/about-the-rights-and-freedom-series',
-};
-
-describe('decideSponsoredContentBranding', () => {
- it('returns sponsored branding if all cards in a collection have sponsored branding, they have the same sponsor and front has the same branding', () => {
- expect(
- decideSponsoredContentBranding(
- 4,
- [
- editionBrandingWithSponsor,
- editionBrandingWithSponsor,
- editionBrandingWithSponsor,
- editionBrandingWithSponsor,
- ],
- true,
- 'fixed/small/slow-IV',
- ),
- ).toEqual(editionBrandingWithSponsor);
- });
-
- it('returns undefined if at least one card in the collection does not have sponsored branding', () => {
- expect(
- decideSponsoredContentBranding(
- 4,
- Array(3).fill(editionBrandingWithSponsor),
- false,
- 'fixed/small/slow-IV',
- ),
- ).toEqual(undefined);
- });
-
- it('returns undefined if at least one card in the collection has different sponsor from the rest', () => {
- const editionBrandingWithDifferentSponsor: Branding = {
- brandingType: {
- name: 'sponsored',
- },
- sponsorName: 'Bertha Foundation',
- logo: {
- src: 'https://static.theguardian.com/commercial/sponsor/28/Mar/2017/d1105d96-b067-4091-81ce-02f7c7c24173-Logo.png',
- dimensions: {
- width: 108,
- height: 45,
- },
- link: 'http://www.berthafoundation.org/',
- label: 'Supported by',
- },
- logoForDarkBackground: {
- src: 'https://static.theguardian.com/commercial/sponsor/28/Mar/2017/8aabe16e-fd35-4f3c-a39b-3ec149877f56-Logo.png',
- dimensions: {
- width: 108,
- height: 45,
- },
- link: 'http://www.berthafoundation.org/',
- label: 'Supported by',
- },
- aboutThisLink:
- 'https://www.theguardian.com/info/2016/aug/31/about-guardian-bertha-documentary-partnership',
- };
-
- expect(
- decideSponsoredContentBranding(
- 4,
- Array(3)
- .fill(editionBrandingWithSponsor)
- .fill(editionBrandingWithDifferentSponsor),
- false,
- 'fixed/small/slow-IV',
- ),
- ).toEqual(undefined);
- });
-
- it('returns undefined if front does not have the same branding as the cards', () => {
- expect(
- decideSponsoredContentBranding(
- 4,
- Array(4).fill(editionBrandingWithSponsor),
- false,
- 'fixed/small/slow-IV',
- ),
- ).toEqual(undefined);
- });
-
- it('returns undefined if the container is a thrasher', () => {
- expect(
- decideSponsoredContentBranding(
- 4,
- Array(4).fill(editionBrandingWithSponsor),
- true,
- 'fixed/thrasher',
- ),
- ).toEqual(undefined);
- });
-});
diff --git a/dotcom-rendering/src/model/decideSponsoredContentBranding.ts b/dotcom-rendering/src/model/decideSponsoredContentBranding.ts
deleted file mode 100644
index af9488a9617..00000000000
--- a/dotcom-rendering/src/model/decideSponsoredContentBranding.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import type { Branding } from '../types/branding';
-import type { DCRContainerType } from '../types/front';
-
-export const decideSponsoredContentBranding = (
- collectionsLength: number,
- allCardsBranding: Branding[],
- editionHasBranding: boolean,
- collectionType: DCRContainerType,
-): Branding | undefined => {
- const allCardsHaveBranding = collectionsLength === allCardsBranding.length;
-
- const allCardsHaveSponsoredBranding =
- allCardsHaveBranding &&
- allCardsBranding.every(
- (branding) => branding.brandingType?.name === 'sponsored',
- );
- const allCardsHaveTheSameSponsor =
- allCardsHaveBranding &&
- allCardsBranding.every(
- (branding) =>
- branding.sponsorName === allCardsBranding[0]?.sponsorName,
- );
-
- const isNotAThrasher = collectionType !== 'fixed/thrasher';
-
- const shouldHaveSponsorBranding =
- isNotAThrasher &&
- editionHasBranding &&
- allCardsHaveSponsoredBranding &&
- allCardsHaveTheSameSponsor;
-
- return shouldHaveSponsorBranding ? allCardsBranding[0] : undefined;
-};
diff --git a/dotcom-rendering/src/model/enhanceCollections.ts b/dotcom-rendering/src/model/enhanceCollections.ts
index 2ac8af12f18..72006d32c69 100644
--- a/dotcom-rendering/src/model/enhanceCollections.ts
+++ b/dotcom-rendering/src/model/enhanceCollections.ts
@@ -1,15 +1,8 @@
-import { isNonNullable } from '@guardian/libs';
-import { pickBrandingForEdition } from '../lib/branding';
+import { decideCollectionBranding } from '../lib/branding';
import type { EditionId } from '../lib/edition';
import type { Branding } from '../types/branding';
-import type {
- DCRCollectionType,
- FECollectionType,
- FEFrontCard,
-} from '../types/front';
-import { decideEditorialBadge, decidePaidContentBadge } from './decideBadge';
+import type { DCRCollectionType, FECollectionType } from '../types/front';
import { decideContainerPalette } from './decideContainerPalette';
-import { decideSponsoredContentBranding } from './decideSponsoredContentBranding';
import { enhanceCards } from './enhanceCards';
import { enhanceTreats } from './enhanceTreats';
import { groupCards } from './groupCards';
@@ -31,16 +24,20 @@ const isSupported = (collection: FECollectionType): boolean =>
)
);
-function getBrandingFromCards(
- allCards: FEFrontCard[],
- editionId: EditionId,
-): Branding[] {
- return allCards
- .map((card) =>
- pickBrandingForEdition(card.properties.editionBrandings, editionId),
- )
- .filter(isNonNullable);
-}
+const findCollectionSuitableForFrontBranding = (
+ collections: FECollectionType[],
+) => {
+ // Find the lowest indexed collection that COULD display branding
+ const index = collections.findIndex(
+ ({ collectionType }) => collectionType !== 'fixed/thrasher',
+ );
+ // `findIndex` returns -1 when no element is found
+ // Treat that instead as undefined
+ if (index === -1) {
+ return undefined;
+ }
+ return index;
+};
export const enhanceCollections = ({
collections,
@@ -49,7 +46,7 @@ export const enhanceCollections = ({
discussionApiUrl,
frontBranding,
onPageDescription,
- isPaidContent,
+ isOnPaidContentFront,
}: {
collections: FECollectionType[];
editionId: EditionId;
@@ -57,17 +54,21 @@ export const enhanceCollections = ({
discussionApiUrl: string;
frontBranding: Branding | undefined;
onPageDescription?: string;
- isPaidContent?: boolean;
+ isOnPaidContentFront?: boolean;
}): DCRCollectionType[] => {
+ const indexToShowFrontBranding =
+ findCollectionSuitableForFrontBranding(collections);
return collections.filter(isSupported).map((collection, index) => {
const { id, displayName, collectionType, hasMore, href, description } =
collection;
const allCards = [...collection.curated, ...collection.backfill];
- const allBranding = getBrandingFromCards(allCards, editionId);
- const allCardsHaveBranding = allCards.length === allBranding.length;
- const isCollectionPaidContent = allBranding.every(
- ({ brandingType }) => brandingType?.name === 'paid-content',
- );
+ const collectionBranding = decideCollectionBranding({
+ frontBranding,
+ couldDisplayFrontBranding: index === indexToShowFrontBranding,
+ seriesTag: collection.config.href,
+ cards: allCards,
+ editionId,
+ });
const containerPalette = decideContainerPalette(
collection.config.metadata?.map((meta) => meta.type),
@@ -77,9 +78,8 @@ export const enhanceCollections = ({
*/
{
canBeBranded:
- !isPaidContent &&
- allCardsHaveBranding &&
- isCollectionPaidContent,
+ !isOnPaidContentFront &&
+ collectionBranding?.kind === 'paid-content',
},
);
@@ -93,20 +93,7 @@ export const enhanceCollections = ({
collectionType,
href,
containerPalette,
- editorialBadge: decideEditorialBadge(collection.config.href),
- paidContentBadge: decidePaidContentBadge(
- // We only try to use a branded badge for paid content
- isCollectionPaidContent && allCardsHaveBranding
- ? allBranding
- : undefined,
- ),
- sponsoredContentBranding: decideSponsoredContentBranding(
- allCards.length,
- allBranding,
- // TODO(@chrislomaxjones) Read the full front branding value
- !!frontBranding,
- collectionType,
- ),
+ collectionBranding,
grouped: groupCards(
collectionType,
collection.curated,
diff --git a/dotcom-rendering/src/server/index.front.web.ts b/dotcom-rendering/src/server/index.front.web.ts
index 78170bc8f84..2b10ddd7231 100644
--- a/dotcom-rendering/src/server/index.front.web.ts
+++ b/dotcom-rendering/src/server/index.front.web.ts
@@ -33,7 +33,7 @@ const enhanceFront = (body: unknown): DCRFrontType => {
pageId: data.pageId,
onPageDescription:
data.pressedPage.frontProperties.onPageDescription,
- isPaidContent: data.config.isPaidContent,
+ isOnPaidContentFront: data.config.isPaidContent,
discussionApiUrl: data.config.discussionApiUrl,
frontBranding: pickBrandingForEdition(
data.pressedPage.frontProperties.commercial
diff --git a/dotcom-rendering/src/types/branding.ts b/dotcom-rendering/src/types/branding.ts
index f575155fe7b..01d70221440 100644
--- a/dotcom-rendering/src/types/branding.ts
+++ b/dotcom-rendering/src/types/branding.ts
@@ -1,4 +1,5 @@
import type { EditionId } from '../lib/edition';
+import type { DCRBadgeType } from './badge';
type BrandingLogo = {
src: string;
@@ -29,3 +30,32 @@ export interface EditionBranding {
};
branding?: Branding;
}
+
+/**
+ * Branding that can be applied to an entire collection on a front
+ *
+ * The `kind` property here is used to disambiguate the kind of branding
+ * a collection can have:
+ * - Those funded by a third party
+ * - Those that have an editorial badge from a hardcoded set
+ */
+export type CollectionBranding =
+ | {
+ /**
+ * A collection can have branding that is funded by a third party
+ */
+ kind: BrandingType['name'];
+ /**
+ * In certain circumstances a collection might display the branding on behalf of an entire front
+ * In that case this property is true
+ */
+ isFrontBranding: boolean;
+ branding: Branding;
+ }
+ | {
+ /**
+ * Collections from certain series can have an 'editorial' badge selected from a hardcoded set
+ */
+ kind: 'editorial';
+ badge: DCRBadgeType;
+ };
diff --git a/dotcom-rendering/src/types/front.ts b/dotcom-rendering/src/types/front.ts
index 8a1db93b031..aed24ef9a2d 100644
--- a/dotcom-rendering/src/types/front.ts
+++ b/dotcom-rendering/src/types/front.ts
@@ -1,8 +1,7 @@
import type { ArticleSpecial, Pillar } from '@guardian/libs';
import type { SharedAdTargeting } from '../lib/ad-targeting';
import type { EditionId } from '../lib/edition';
-import type { DCRBadgeType } from './badge';
-import type { Branding, EditionBranding } from './branding';
+import type { Branding, CollectionBranding, EditionBranding } from './branding';
import type { ServerSideTests, Switches } from './config';
import type { Image } from './content';
import type { FooterType } from './footer';
@@ -401,9 +400,7 @@ export type DCRCollectionType = {
* will always be `false`.
**/
canShowMore?: boolean;
- editorialBadge?: DCRBadgeType;
- paidContentBadge?: DCRBadgeType;
- sponsoredContentBranding?: Branding;
+ collectionBranding?: CollectionBranding;
targetedTerritory?: Territory;
};