diff --git a/dotcom-rendering/src/layouts/GalleryLayout.tsx b/dotcom-rendering/src/layouts/GalleryLayout.tsx
index 3079fa02fa3..3debef0ce33 100644
--- a/dotcom-rendering/src/layouts/GalleryLayout.tsx
+++ b/dotcom-rendering/src/layouts/GalleryLayout.tsx
@@ -7,6 +7,8 @@ import {
} from '@guardian/source/foundations';
import { Hide } from '@guardian/source/react-components';
import { Fragment } from 'react';
+import { AdPlaceholder } from '../components/AdPlaceholder.apps';
+import { AdPortals } from '../components/AdPortals.importable';
import { AdSlot } from '../components/AdSlot.web';
import { AppsFooter } from '../components/AppsFooter.importable';
import { ArticleHeadline } from '../components/ArticleHeadline';
@@ -31,7 +33,6 @@ import { type ArticleFormat, ArticleSpecial } from '../lib/articleFormat';
import { canRenderAds } from '../lib/canRenderAds';
import { getContributionsServiceUrl } from '../lib/contributions';
import { decideMainMediaCaption } from '../lib/decide-caption';
-import { getAdPositions } from '../lib/getGalleryAdPositions';
import type { NavType } from '../model/extract-nav';
import { palette as themePalette } from '../palette';
import type { Gallery } from '../types/article';
@@ -171,10 +172,6 @@ export const GalleryLayout = (props: WebProps | AppProps) => {
const contributionsServiceUrl = getContributionsServiceUrl(frontendData);
- const adPositions: number[] = renderAds
- ? getAdPositions(gallery.images)
- : [];
-
return (
<>
{isWeb && (
@@ -231,6 +228,11 @@ export const GalleryLayout = (props: WebProps | AppProps) => {
backgroundColor: themePalette('--article-background'),
}}
>
+ {isApps && renderAds && (
+
+
+
+ )}
- {gallery.images.map((element, idx) => {
- const index = idx + 1;
- const shouldShowAds = adPositions.includes(index);
-
+ {gallery.bodyElements.map((element, index) => {
+ const isImage =
+ element._type ===
+ 'model.dotcomrendering.pageElements.ImageBlockElement';
+ const shouldShowAds =
+ element._type ===
+ 'model.dotcomrendering.pageElements.AdPlaceholderBlockElement';
return (
-
-
- {isWeb && shouldShowAds && (
-
+
+ {isImage && (
+
+ )}
+ {shouldShowAds && renderAds && (
+ <>
+ {isWeb && (
+
+ )}
+ {isApps && }
+ >
)}
);
diff --git a/dotcom-rendering/src/lib/getGalleryAdPositions.ts b/dotcom-rendering/src/lib/getGalleryAdPositions.ts
deleted file mode 100644
index 5c2980ff9eb..00000000000
--- a/dotcom-rendering/src/lib/getGalleryAdPositions.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import type { ImageBlockElement } from '../types/content';
-
-/**
- * This function calculates the positions in the gallery where ads should be placed.
- * The ad position will be the same for all breakpoints, after every 4 images will have an ad slot.
- * The logic difference between mobile and desktop slot ids is in GalleryAdSlots.tsx and AdSlot.web.tsx.
- * @param images - An array of ImageBlockElement objects representing the images in the gallery.
- * @returns An array of numbers representing the positions where ads should be placed.
- */
-const getAdPositions = (images: ImageBlockElement[]): number[] => {
- const adPositions = images
- .map((image) => images.indexOf(image) + 1)
- .filter((position) => position % 4 === 0);
- return adPositions;
-};
-
-export { getAdPositions };
diff --git a/dotcom-rendering/src/model/enhance-ad-placeholders.ts b/dotcom-rendering/src/model/enhance-ad-placeholders.ts
index 28bee3b4d90..6a1fa57da40 100644
--- a/dotcom-rendering/src/model/enhance-ad-placeholders.ts
+++ b/dotcom-rendering/src/model/enhance-ad-placeholders.ts
@@ -69,6 +69,63 @@ const insertPlaceholder = (
return [...prevElements, placeholder, currentElement];
};
+const insertPlaceholderAfterCurrentElement = (
+ prevElements: FEElement[],
+ currentElement: FEElement,
+): FEElement[] => {
+ const placeholder: AdPlaceholderBlockElement = {
+ _type: 'model.dotcomrendering.pageElements.AdPlaceholderBlockElement',
+ };
+ return [...prevElements, currentElement, placeholder];
+};
+
+type ReducerAccumulatorGallery = {
+ elements: FEElement[];
+ imageBlockElementCounter: number;
+};
+
+/**
+ * Insert ad placeholders for gallery articles.
+ * Gallery-specific rules:
+ * - Place ads after every 4th image
+ * - Start placing ads after the 4th image
+ * @param elements - The array of elements to enhance
+ * @returns The enhanced array of elements with ad placeholders inserted
+ */
+const insertAdPlaceholdersForGallery = (elements: FEElement[]): FEElement[] => {
+ const elementsWithReducerContext = elements.reduce(
+ (
+ prev: ReducerAccumulatorGallery,
+ currentElement: FEElement,
+ ): ReducerAccumulatorGallery => {
+ const imageBlockElementCounter =
+ currentElement._type ===
+ 'model.dotcomrendering.pageElements.ImageBlockElement'
+ ? prev.imageBlockElementCounter + 1
+ : prev.imageBlockElementCounter;
+
+ const shouldInsertAd = imageBlockElementCounter % 4 === 0;
+
+ return {
+ elements: shouldInsertAd
+ ? insertPlaceholderAfterCurrentElement(
+ prev.elements,
+ currentElement,
+ )
+ : [...prev.elements, currentElement],
+ imageBlockElementCounter,
+ };
+ },
+ // Initial value for reducer function
+ {
+ elements: [],
+ imageBlockElementCounter: 0,
+ },
+ );
+
+ return elementsWithReducerContext.elements;
+};
+
/**
* - elementCounter: the number of paragraphs and images that we've counted when considering ad insertion
* - lastAdIndex: the index of the most recently inserted ad, used to calculate the elements between subsequent ads
@@ -140,10 +197,22 @@ export const enhanceAdPlaceholders =
renderingTarget: RenderingTarget,
shouldHideAds: boolean,
) =>
- (elements: FEElement[]): FEElement[] =>
- renderingTarget === 'Apps' &&
- !shouldHideAds &&
- format.design !== ArticleDesign.LiveBlog &&
- format.design !== ArticleDesign.DeadBlog
- ? insertAdPlaceholders(elements)
- : elements;
+ (elements: FEElement[]): FEElement[] => {
+ if (shouldHideAds) return elements;
+
+ // In galleries the AdPlaceholders are inserted in both
+ // Web & App because the same logic is used for both
+ if (format.design === ArticleDesign.Gallery) {
+ return insertAdPlaceholdersForGallery(elements);
+ }
+
+ if (
+ renderingTarget === 'Apps' &&
+ format.design !== ArticleDesign.LiveBlog &&
+ format.design !== ArticleDesign.DeadBlog
+ ) {
+ return insertAdPlaceholders(elements);
+ }
+
+ return elements;
+ };
diff --git a/dotcom-rendering/src/types/article.ts b/dotcom-rendering/src/types/article.ts
index 869d47a3c9a..4a05a9f9dd7 100644
--- a/dotcom-rendering/src/types/article.ts
+++ b/dotcom-rendering/src/types/article.ts
@@ -18,7 +18,12 @@ import {
type TableOfContentsItem,
} from '../model/enhanceTableOfContents';
import { enhancePinnedPost } from '../model/pinnedPost';
-import type { FEElement, ImageBlockElement, ImageForLightbox } from './content';
+import type {
+ AdPlaceholderBlockElement,
+ FEElement,
+ ImageBlockElement,
+ ImageForLightbox,
+} from './content';
import { type RenderingTarget } from './renderingTarget';
/**
@@ -40,7 +45,7 @@ export type ArticleFields = {
export type Gallery = ArticleFields & {
design: ArticleDesign.Gallery;
- images: ImageBlockElement[];
+ bodyElements: (ImageBlockElement | AdPlaceholderBlockElement)[];
mainMedia: ImageBlockElement;
};
@@ -130,11 +135,13 @@ export const enhanceArticleType = (
design,
display: format.display,
theme: format.theme,
- images: blocks.flatMap((block) =>
+ bodyElements: blocks.flatMap((block) =>
block.elements.filter(
(element) =>
element._type ===
- 'model.dotcomrendering.pageElements.ImageBlockElement',
+ 'model.dotcomrendering.pageElements.ImageBlockElement' ||
+ element._type ===
+ 'model.dotcomrendering.pageElements.AdPlaceholderBlockElement',
),
),
mainMedia: getGalleryMainMedia(