From 6a1aab67bfc5a0e71d5ac43caf5705c5c1fa3be1 Mon Sep 17 00:00:00 2001 From: andresilva-guardian Date: Wed, 17 Jun 2026 15:15:45 +0100 Subject: [PATCH 01/12] Add Braze banner integration to Feast contextual nudge component --- .../FeastContextualNudge.island.tsx | 36 ++++++++++++++++++- dotcom-rendering/src/lib/ArticleRenderer.tsx | 18 +++++++++- .../src/lib/braze/BrazeBannersSystem.tsx | 5 +++ 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx index db65578bac4..aca061f94c2 100644 --- a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx +++ b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx @@ -7,7 +7,12 @@ import { } from '@guardian/source/foundations'; import { LinkButton } from '@guardian/source/react-components'; import { useEffect, useState } from 'react'; +import { + BrazeBannersSystemDisplay, + BrazeBannersSystemPlacementId, +} from '../lib/braze/BrazeBannersSystem'; import { useAB } from '../lib/useAB'; +import { useBraze } from '../lib/useBraze'; import type { StageType } from '../types/config'; import type { RecipeBlockElement } from '../types/content'; import { useConfig } from './ConfigContext'; @@ -122,6 +127,8 @@ type FeastContextualNudgeProps = { recipeArticleTitle: string; pageId: string; isDev: boolean; + nudgeIndex: number; + idApiUrl: string | undefined; }; /** @@ -142,12 +149,16 @@ export const FeastContextualNudge = ({ recipeArticleTitle, pageId, isDev, + nudgeIndex, + idApiUrl, }: FeastContextualNudgeProps) => { const abTests = useAB(); const isVariant = abTests?.isUserInTestGroup('feast-recipe-nudge', 'variant-1') ?? false; - const { darkModeAvailable } = useConfig(); + const { darkModeAvailable, renderingTarget } = useConfig(); + + const { braze } = useBraze(idApiUrl ?? '', renderingTarget); const [isStorybook, setIsStorybook] = useState(false); useEffect(() => { @@ -173,6 +184,29 @@ export const FeastContextualNudge = ({ if (!isVariant) return null; + // If idApiUrl is defined and Braze has a banner for this placement slot, + // render the Braze banner instead of the native nudge. + if (idApiUrl !== undefined) { + const placementId = + BrazeBannersSystemPlacementId[ + `FeastContextualNudge${nudgeIndex}` as keyof typeof BrazeBannersSystemPlacementId + ]; + const banner = braze?.getBanner(placementId) ?? null; + if (banner && braze) { + return ( + + ); + } + } + return (
{section.subheadingEl} - {section.recipe && ( + {section.recipe && nudgeIndex !== null && ( )} diff --git a/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx b/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx index ffc732ebe0e..d4f59eaf707 100644 --- a/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx +++ b/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx @@ -106,6 +106,11 @@ export const brazeBannersSystemLogger = { export enum BrazeBannersSystemPlacementId { EndOfArticle = 'dotcom-rendering_end-of-article', Banner = 'dotcom-rendering_banner', + FeastContextualNudge1 = 'dotcom-rendering_feast-contextual-nudge-1', + FeastContextualNudge2 = 'dotcom-rendering_feast-contextual-nudge-2', + FeastContextualNudge3 = 'dotcom-rendering_feast-contextual-nudge-3', + FeastContextualNudge4 = 'dotcom-rendering_feast-contextual-nudge-4', + FeastContextualNudge5 = 'dotcom-rendering_feast-contextual-nudge-5', } /** From 79f34e598728a4a88c9c4febda41699eb67d53ee Mon Sep 17 00:00:00 2001 From: andresilva-guardian Date: Wed, 17 Jun 2026 15:20:27 +0100 Subject: [PATCH 02/12] Add nudgeIndex and idApiUrl to FeastContextualNudge story args --- .../src/components/FeastContextualNudge.stories.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dotcom-rendering/src/components/FeastContextualNudge.stories.tsx b/dotcom-rendering/src/components/FeastContextualNudge.stories.tsx index 89eff8100fb..4a86deb5d9d 100644 --- a/dotcom-rendering/src/components/FeastContextualNudge.stories.tsx +++ b/dotcom-rendering/src/components/FeastContextualNudge.stories.tsx @@ -43,6 +43,8 @@ const meta = { recipeArticleTitle: "Meera Sodha's spring onion pancakes", recipe: mockRecipe, isDev: true, + nudgeIndex: 1, + idApiUrl: undefined, }, parameters: { chromatic: { viewports: [375, 740, 980] }, From 2bcaf2f173ace7168a185da19a43e57c3cc8a7c1 Mon Sep 17 00:00:00 2001 From: andresilva-guardian Date: Wed, 24 Jun 2026 11:18:09 +0100 Subject: [PATCH 03/12] Add GetContext message type and context parameter to Braze Banners System --- .../src/lib/braze/BrazeBannersSystem.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx b/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx index d4f59eaf707..cb7652a5133 100644 --- a/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx +++ b/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx @@ -318,6 +318,7 @@ enum BrazeBannersSystemMessageType { GetSettingsPropertyValue = 'BRAZE_BANNERS_SYSTEM:GET_SETTINGS_PROPERTY_VALUE', NavigateToUrl = 'BRAZE_BANNERS_SYSTEM:NAVIGATE_TO_URL', DismissBanner = 'BRAZE_BANNERS_SYSTEM:DISMISS_BANNER', + GetContext = 'BRAZE_BANNERS_SYSTEM:GET_CONTEXT', } /** @@ -452,16 +453,20 @@ const runCssCheckerOnBrazeBanner = ( * Displays a Braze Banner using the Braze Banners System. * @param meta Meta information required to display the banner * @param idApiUrl Identity API URL for newsletter subscriptions + * @param stage Current stage of the application (e.g., PROD, CODE) + * @param context Additional context for the banner (optional) * @returns React component that renders the Braze Banner */ export const BrazeBannersSystemDisplay = ({ meta, idApiUrl, stage, + context, }: { meta: BrazeBannersSystemMeta; idApiUrl: string; stage: StageType; + context?: unknown; }) => { const supportOrigin = isProd(stage) ? 'https://support.theguardian.com' @@ -750,6 +755,9 @@ export const BrazeBannersSystemDisplay = ({ | { type: BrazeBannersSystemMessageType.DismissBanner; } + | { + type: BrazeBannersSystemMessageType.GetContext; + } >, ) => { if ( @@ -891,6 +899,14 @@ export const BrazeBannersSystemDisplay = ({ case BrazeBannersSystemMessageType.DismissBanner: dismissBanner(); break; + case BrazeBannersSystemMessageType.GetContext: + postMessageToBrazeBanner( + BrazeBannersSystemMessageType.GetContext, + { + context, + }, + ); + break; } }; @@ -907,6 +923,7 @@ export const BrazeBannersSystemDisplay = ({ createReminder, dismissBanner, postMessageToBrazeBanner, + context, ]); // Log Impressions when the banner is seen, using the hasBeenSeen value from the useIsInView hook From 1a2962c6c57e611e82934f77e52e5cc12b33048e Mon Sep 17 00:00:00 2001 From: andresilva-guardian Date: Wed, 24 Jun 2026 11:18:19 +0100 Subject: [PATCH 04/12] Add context prop to FeastContextualNudge component --- .../src/components/FeastContextualNudge.island.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx index 7d5098444de..ca51df02be8 100644 --- a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx +++ b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx @@ -203,6 +203,14 @@ export const FeastContextualNudge = ({ }} idApiUrl={idApiUrl} stage={stage} + context={{ + recipe, + recipeArticleTitle, + pageId, + isDev, + nudgeIndex, + darkMode: darkModeAvailable, + }} /> ); } From 0c4ea5e610ff88f1edb2e8a687540b1cf99ee8cd Mon Sep 17 00:00:00 2001 From: andresilva-guardian Date: Wed, 24 Jun 2026 12:53:45 +0100 Subject: [PATCH 05/12] Remove unused text color variables from FeastContextualNudge component --- .../src/components/FeastContextualNudge.island.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx index ca51df02be8..64986cd8a88 100644 --- a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx +++ b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx @@ -21,8 +21,6 @@ import { useConfig } from './ConfigContext'; const FEAST_BG = '#F3F3E9'; const FEAST_BG_DARK = '#2B2B26'; -const FEAST_TEXT = sourcePalette.neutral[10]; -const FEAST_TEXT_DARK = sourcePalette.neutral[100]; const FEAST_SUBTEXT = sourcePalette.neutral[20]; const FEAST_SUBTEXT_DARK = sourcePalette.neutral[93]; const FEAST_GREEN = '#68773C'; @@ -34,14 +32,12 @@ const FEAST_BORDER_DARK = FEAST_GREEN; const lightVars = css` --feast-nudge-bg: ${FEAST_BG}; - --feast-nudge-heading: ${FEAST_TEXT}; --feast-nudge-subtext: ${FEAST_SUBTEXT}; --feast-nudge-border: ${FEAST_BORDER}; `; const darkVars = css` --feast-nudge-bg: ${FEAST_BG_DARK}; - --feast-nudge-heading: ${FEAST_TEXT_DARK}; --feast-nudge-subtext: ${FEAST_SUBTEXT_DARK}; --feast-nudge-border: ${FEAST_BORDER_DARK}; `; From d82581c256499d85a8378733bc6664bf07011a55 Mon Sep 17 00:00:00 2001 From: andresilva-guardian Date: Wed, 24 Jun 2026 12:54:39 +0100 Subject: [PATCH 06/12] Refactor FeastContextualNudge component to replace subtext variables with text variables --- .../src/components/FeastContextualNudge.island.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx index 64986cd8a88..4f4bf55c924 100644 --- a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx +++ b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx @@ -21,8 +21,8 @@ import { useConfig } from './ConfigContext'; const FEAST_BG = '#F3F3E9'; const FEAST_BG_DARK = '#2B2B26'; -const FEAST_SUBTEXT = sourcePalette.neutral[20]; -const FEAST_SUBTEXT_DARK = sourcePalette.neutral[93]; +const FEAST_TEXT = sourcePalette.neutral[20]; +const FEAST_TEXT_DARK = sourcePalette.neutral[93]; const FEAST_GREEN = '#68773C'; const FEAST_GREEN_HOVER = '#4d5c2b'; const FEAST_BORDER = FEAST_GREEN; @@ -32,13 +32,13 @@ const FEAST_BORDER_DARK = FEAST_GREEN; const lightVars = css` --feast-nudge-bg: ${FEAST_BG}; - --feast-nudge-subtext: ${FEAST_SUBTEXT}; + --feast-nudge-text: ${FEAST_TEXT}; --feast-nudge-border: ${FEAST_BORDER}; `; const darkVars = css` --feast-nudge-bg: ${FEAST_BG_DARK}; - --feast-nudge-subtext: ${FEAST_SUBTEXT_DARK}; + --feast-nudge-text: ${FEAST_TEXT_DARK}; --feast-nudge-border: ${FEAST_BORDER_DARK}; `; @@ -110,7 +110,7 @@ const buttonWrapperStyles = css` const descriptionStyles = css` ${article15}; - color: var(--feast-nudge-subtext); + color: var(--feast-nudge-text); b { font-weight: bold; } From d18edc4fafdf541e4954e577ad338bc213186f08 Mon Sep 17 00:00:00 2001 From: andresilva-guardian Date: Wed, 24 Jun 2026 13:34:14 +0100 Subject: [PATCH 07/12] Wrap BrazeBannersSystemDisplay in a div with aria-description for accessibility and a margin --- .../FeastContextualNudge.island.tsx | 42 +++++++++++-------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx index 4f4bf55c924..9e8c1ef1464 100644 --- a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx +++ b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx @@ -191,23 +191,31 @@ export const FeastContextualNudge = ({ const banner = braze?.getBanner(placementId) ?? null; if (banner && braze) { return ( - +
+ +
); } } From e6f095076b9afce78eb7865a6dac56bcffb46661 Mon Sep 17 00:00:00 2001 From: andresilva-guardian Date: Thu, 25 Jun 2026 16:32:07 +0100 Subject: [PATCH 08/12] Add placement management for Braze Banners System and update refresh logic --- .../src/lib/braze/BrazeBannersSystem.tsx | 53 ++++++++++++++++++- .../src/lib/braze/buildBrazeMessaging.ts | 6 ++- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx b/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx index cb7652a5133..dea5bdd2808 100644 --- a/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx +++ b/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx @@ -113,6 +113,42 @@ export enum BrazeBannersSystemPlacementId { FeastContextualNudge5 = 'dotcom-rendering_feast-contextual-nudge-5', } +/** + * Maximum number of placements per refresh request, as per Braze documentation: + * https://www.braze.com/docs/developer_guide/banners/placements/#requestBannersRefresh + */ +const BRAZE_MAX_PLACEMENTS_PER_REFRESH = 10; + +/** + * Maps each gu-island component name to the Braze Banner placement IDs it is + * responsible for rendering. When a gu-island element is absent from the + * server-rendered DOM, its placements are excluded from the refresh request, + * keeping requests within Braze's 10-placement cap and avoiding wasted + * rate-limit tokens on placements that cannot appear on the current page. + */ +const ISLAND_PLACEMENT_MAP: Record = { + StickyBottomBanner: [BrazeBannersSystemPlacementId.Banner], + SlotBodyEnd: [BrazeBannersSystemPlacementId.EndOfArticle], + FeastContextualNudge: [ + BrazeBannersSystemPlacementId.FeastContextualNudge1, + BrazeBannersSystemPlacementId.FeastContextualNudge2, + BrazeBannersSystemPlacementId.FeastContextualNudge3, + BrazeBannersSystemPlacementId.FeastContextualNudge4, + BrazeBannersSystemPlacementId.FeastContextualNudge5, + ], +}; + +/** + * Determines which Braze Banner placement IDs are needed on the current page + * by checking which gu-island elements were rendered into the DOM server-side. + * Only placements whose corresponding island is present are included. + */ +export function getPagePlacements(): BrazeBannersSystemPlacementId[] { + return Object.entries(ISLAND_PLACEMENT_MAP).flatMap(([islandName, ids]) => + document.querySelector(`gu-island[name="${islandName}"]`) ? ids : [], + ); +} + /** * Trigger a refresh of Braze Banners System banners * "Each call to requestBannersRefresh consumes one token. If you attempt a refresh @@ -126,7 +162,20 @@ export enum BrazeBannersSystemPlacementId { * @param braze The Braze instance * @returns A promise that resolves when the refresh is complete */ -export function refreshBanners(braze: BrazeInstance): Promise { +export function refreshBanners( + braze: BrazeInstance, + placements: BrazeBannersSystemPlacementId[], +): Promise { + brazeBannersSystemLogger.info( + `🔄 Requesting ${placements.length} placement(s): ${placements.join(', ')}`, + ); + + if (placements.length > BRAZE_MAX_PLACEMENTS_PER_REFRESH) { + brazeBannersSystemLogger.warn( + `⚠️ ${placements.length} placements requested, but Braze only processes the first ${BRAZE_MAX_PLACEMENTS_PER_REFRESH} per refresh request. See https://www.braze.com/docs/developer_guide/banners/placements/#requestBannersRefresh`, + ); + } + let timeoutId: NodeJS.Timeout; // Create the Timeout Promise @@ -144,7 +193,7 @@ export function refreshBanners(braze: BrazeInstance): Promise { // Create the Braze Promise const brazeRequest = new Promise((resolve) => { braze.requestBannersRefresh( - Object.values(BrazeBannersSystemPlacementId), + placements, () => { brazeBannersSystemLogger.info('✅ Refresh completed.'); clearTimeout(timeoutId); // Cancel the timeout diff --git a/dotcom-rendering/src/lib/braze/buildBrazeMessaging.ts b/dotcom-rendering/src/lib/braze/buildBrazeMessaging.ts index d5341e41154..722b1906626 100644 --- a/dotcom-rendering/src/lib/braze/buildBrazeMessaging.ts +++ b/dotcom-rendering/src/lib/braze/buildBrazeMessaging.ts @@ -20,6 +20,7 @@ import { } from '../hasCurrentBrazeUser'; import { brazeBannersSystemLogger, + getPagePlacements, isDevelopmentDomain, refreshBanners, } from './BrazeBannersSystem'; @@ -146,7 +147,10 @@ export const buildBrazeMessaging = async ( // (Note that this method can only be called once per session.) // Since we want to suppress In-App Messages if a banner exists, we must // call requestBannersRefresh before openSession. - await refreshBanners(braze); + const placements = getPagePlacements(); + if (placements.length > 0) { + await refreshBanners(braze, placements); + } braze.openSession(); From 4802c0e8bcae7127556651527fabef6b8061f2ab Mon Sep 17 00:00:00 2001 From: andresilva-guardian Date: Fri, 26 Jun 2026 11:01:00 +0100 Subject: [PATCH 09/12] Enhance logging for Braze banners updates to provide clearer information --- dotcom-rendering/src/lib/braze/buildBrazeMessaging.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/dotcom-rendering/src/lib/braze/buildBrazeMessaging.ts b/dotcom-rendering/src/lib/braze/buildBrazeMessaging.ts index 722b1906626..019632093cb 100644 --- a/dotcom-rendering/src/lib/braze/buildBrazeMessaging.ts +++ b/dotcom-rendering/src/lib/braze/buildBrazeMessaging.ts @@ -133,7 +133,10 @@ export const buildBrazeMessaging = async ( // This callback runs every time Braze has new data (initially empty, then populated) const subscriptionId = braze.subscribeToBannersUpdates( (banners) => { - brazeBannersSystemLogger.log('📢 Check:', banners); + brazeBannersSystemLogger.log( + '📢 These Banners were updated:', + banners, + ); }, ); brazeBannersSystemLogger.info( From bcc02f4680b60862409a4ed31554818277862ccd Mon Sep 17 00:00:00 2001 From: andresilva-guardian Date: Fri, 26 Jun 2026 11:33:17 +0100 Subject: [PATCH 10/12] Add stale placement handling for Braze banners to improve fallback logic --- .../FeastContextualNudge.island.tsx | 15 +- .../src/lib/braze/BrazeBannersSystem.tsx | 148 ++++++++++++++++-- 2 files changed, 149 insertions(+), 14 deletions(-) diff --git a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx index 9e8c1ef1464..1e4240368f9 100644 --- a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx +++ b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx @@ -10,6 +10,7 @@ import { useEffect, useState } from 'react'; import { BrazeBannersSystemDisplay, BrazeBannersSystemPlacementId, + isPlacementStale, } from '../lib/braze/BrazeBannersSystem'; import { useAB } from '../lib/useAB'; import { useBraze } from '../lib/useBraze'; @@ -188,7 +189,19 @@ export const FeastContextualNudge = ({ BrazeBannersSystemPlacementId[ `FeastContextualNudge${nudgeIndex}` as keyof typeof BrazeBannersSystemPlacementId ]; - const banner = braze?.getBanner(placementId) ?? null; + + // Guard against stale placements: if the last requestBannersRefresh + // was rate-limited AND this placement has suppressOnStale: true in + // ISLAND_PLACEMENT_MAP, skip getBanner() and fall through to the + // native nudge below. + // + // With the current config (suppressOnStale: false for + // FeastContextualNudge) this check always passes — it exists for + // forward-compatibility if the config is ever changed to true. + const banner = !isPlacementStale(placementId) + ? (braze?.getBanner(placementId) ?? null) + : null; + if (banner && braze) { return (
= { - StickyBottomBanner: [BrazeBannersSystemPlacementId.Banner], - SlotBodyEnd: [BrazeBannersSystemPlacementId.EndOfArticle], - FeastContextualNudge: [ - BrazeBannersSystemPlacementId.FeastContextualNudge1, - BrazeBannersSystemPlacementId.FeastContextualNudge2, - BrazeBannersSystemPlacementId.FeastContextualNudge3, - BrazeBannersSystemPlacementId.FeastContextualNudge4, - BrazeBannersSystemPlacementId.FeastContextualNudge5, - ], +const ISLAND_PLACEMENT_MAP: Record< + string, + { + /** The Braze Banner placement IDs this island is responsible for. */ + ids: BrazeBannersSystemPlacementId[]; + /** + * Whether to suppress rendering when the last refresh was rate-limited. + * See the ISLAND_PLACEMENT_MAP JSDoc above for the full contract. + */ + suppressOnStale: boolean; + } +> = { + // MRR placements: suppress on stale — avoid showing outdated campaigns + // that could mislead readers or contradict their current eligibility. + StickyBottomBanner: { + ids: [BrazeBannersSystemPlacementId.Banner], + suppressOnStale: true, + }, + SlotBodyEnd: { + ids: [BrazeBannersSystemPlacementId.EndOfArticle], + suppressOnStale: true, + }, + // Feast placements: do NOT suppress on stale. FeastContextualNudge has a + // native "Download the app" card as a fallback — if the Braze banner is + // unavailable or stale, that fallback renders instead of a blank slot. + FeastContextualNudge: { + ids: [ + BrazeBannersSystemPlacementId.FeastContextualNudge1, + BrazeBannersSystemPlacementId.FeastContextualNudge2, + BrazeBannersSystemPlacementId.FeastContextualNudge3, + BrazeBannersSystemPlacementId.FeastContextualNudge4, + BrazeBannersSystemPlacementId.FeastContextualNudge5, + ], + suppressOnStale: false, + }, }; /** @@ -144,11 +181,54 @@ const ISLAND_PLACEMENT_MAP: Record = { * Only placements whose corresponding island is present are included. */ export function getPagePlacements(): BrazeBannersSystemPlacementId[] { - return Object.entries(ISLAND_PLACEMENT_MAP).flatMap(([islandName, ids]) => - document.querySelector(`gu-island[name="${islandName}"]`) ? ids : [], + return Object.entries(ISLAND_PLACEMENT_MAP).flatMap( + ([islandName, { ids }]) => + document.querySelector(`gu-island[name="${islandName}"]`) + ? ids + : [], ); } +/** + * Pre-computed set of placement IDs that opt-in to stale suppression. + * Derived once at module load time from ISLAND_PLACEMENT_MAP entries where + * suppressOnStale is true — avoids re-computing on every refreshBanners call. + */ +const STALE_SUPPRESSABLE_PLACEMENTS = new Set( + Object.values(ISLAND_PLACEMENT_MAP) + .filter(({ suppressOnStale }) => suppressOnStale) + .flatMap(({ ids }) => ids), +); + +/** + * Tracks placement IDs whose most-recent requestBannersRefresh call failed + * due to Braze's rate-limiter. When a placement is in this set, getBanner() + * may still return cached data from a previous session — but we actively + * suppress rendering to avoid showing outdated campaigns. + * + * Only placements with suppressOnStale: true in ISLAND_PLACEMENT_MAP are ever + * added here. Entries are removed when a subsequent refresh succeeds. + * + * This is module-level state. DCR has no SPA navigation — every page visit + * is a full reload — so this set always starts empty on each page. + */ +const stalePlacements = new Set(); + +/** + * Returns true if the given placement was marked stale because its last + * requestBannersRefresh call was rate-limited by Braze. When true, consumers + * should skip getBanner() and not render the Braze banner. + * + * Note: with the current config, FeastContextualNudge placements have + * suppressOnStale: false and will never be stale. This function is exported + * for forward-compatibility if that config changes in a future iteration. + * + * @param id The placement ID to check. + */ +export function isPlacementStale(id: BrazeBannersSystemPlacementId): boolean { + return stalePlacements.has(id); +} + /** * Trigger a refresh of Braze Banners System banners * "Each call to requestBannersRefresh consumes one token. If you attempt a refresh @@ -195,12 +275,40 @@ export function refreshBanners( braze.requestBannersRefresh( placements, () => { + // On success, lift stale status for any suppressable placements + // in this batch so a future re-refresh can restore them cleanly. + for (const id of placements) { + if (STALE_SUPPRESSABLE_PLACEMENTS.has(id)) { + stalePlacements.delete(id); + } + } brazeBannersSystemLogger.info('✅ Refresh completed.'); clearTimeout(timeoutId); // Cancel the timeout resolve(); }, () => { - brazeBannersSystemLogger.warn('⚠️ Refresh failed.'); + // The errorCallback fires when Braze's rate-limit tokens are + // exhausted. getBanner() still returns the last-cached banner, + // but that data may be outdated. For placements with + // suppressOnStale: true, mark them stale so that + // canShowBrazeBannersSystem (and direct consumers like + // FeastContextualNudge) will not render the cached banner. + const markedStale: BrazeBannersSystemPlacementId[] = []; + for (const id of placements) { + if (STALE_SUPPRESSABLE_PLACEMENTS.has(id)) { + stalePlacements.add(id); + markedStale.push(id); + } + } + if (markedStale.length > 0) { + brazeBannersSystemLogger.warn( + `⚠️ Refresh failed (rate-limited). Marked ${markedStale.length} placement(s) as stale: ${markedStale.join(', ')}`, + ); + } else { + brazeBannersSystemLogger.warn( + '⚠️ Refresh failed (rate-limited). No placements marked stale.', + ); + } clearTimeout(timeoutId); // Cancel the timeout resolve(); }, @@ -282,6 +390,20 @@ export const canShowBrazeBannersSystem = async ( return { show: false }; } + /** + * Suppress this placement if it was marked stale by a failed refresh. + * getBanner() still returns cached data when rate-limited, so we must + * check stalePlacements *before* calling it to avoid rendering outdated + * campaigns. Only placements with suppressOnStale: true in + * ISLAND_PLACEMENT_MAP can ever be stale (currently: Banner, EndOfArticle). + */ + if (stalePlacements.has(placementId)) { + brazeBannersSystemLogger.info( + `Placement "${placementId}" is stale (last refresh was rate-limited). Not showing banner.`, + ); + return { show: false }; + } + /** * Banner for the placement ID. * It is an object of type Banner, if a banner with the given placement ID exists. From 25b88c1be94e3661fa6dd96ffca19d0eba66fb10 Mon Sep 17 00:00:00 2001 From: andresilva-guardian Date: Fri, 26 Jun 2026 11:38:54 +0100 Subject: [PATCH 11/12] Refactor Braze Banners System to implement per-placement stale suppression configuration --- .../FeastContextualNudge.island.tsx | 10 +- .../src/lib/braze/BrazeBannersSystem.tsx | 97 +++++++++---------- 2 files changed, 50 insertions(+), 57 deletions(-) diff --git a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx index 1e4240368f9..56626543be7 100644 --- a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx +++ b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx @@ -192,12 +192,12 @@ export const FeastContextualNudge = ({ // Guard against stale placements: if the last requestBannersRefresh // was rate-limited AND this placement has suppressOnStale: true in - // ISLAND_PLACEMENT_MAP, skip getBanner() and fall through to the - // native nudge below. + // PLACEMENT_SUPPRESS_ON_STALE, skip getBanner() and fall through to + // the native nudge below. // - // With the current config (suppressOnStale: false for - // FeastContextualNudge) this check always passes — it exists for - // forward-compatibility if the config is ever changed to true. + // Each FeastContextualNudge placement ID has its own entry in + // PLACEMENT_SUPPRESS_ON_STALE — change any individual one to `true` + // to suppress that specific nudge on a failed refresh. const banner = !isPlacementStale(placementId) ? (braze?.getBanner(placementId) ?? null) : null; diff --git a/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx b/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx index 7331e6ebefb..1924a9eebe4 100644 --- a/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx +++ b/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx @@ -125,54 +125,46 @@ const BRAZE_MAX_PLACEMENTS_PER_REFRESH = 10; * server-rendered DOM, its placements are excluded from the refresh request, * keeping requests within Braze's 10-placement cap and avoiding wasted * rate-limit tokens on placements that cannot appear on the current page. + */ +const ISLAND_PLACEMENT_MAP: Record = { + StickyBottomBanner: [BrazeBannersSystemPlacementId.Banner], + SlotBodyEnd: [BrazeBannersSystemPlacementId.EndOfArticle], + FeastContextualNudge: [ + BrazeBannersSystemPlacementId.FeastContextualNudge1, + BrazeBannersSystemPlacementId.FeastContextualNudge2, + BrazeBannersSystemPlacementId.FeastContextualNudge3, + BrazeBannersSystemPlacementId.FeastContextualNudge4, + BrazeBannersSystemPlacementId.FeastContextualNudge5, + ], +}; + +/** + * Per-placement stale-suppression config. * - * Each entry also carries a `suppressOnStale` flag that controls what happens - * when requestBannersRefresh is rate-limited by Braze: - * - * - true → the placement IDs are added to `stalePlacements`. Guards in - * canShowBrazeBannersSystem (and direct consumers) will refuse to - * render the cached-but-potentially-outdated banner. - * - false → the cached banner is still shown, or the component falls back to - * its own native UI. Use this when a graceful fallback exists or - * when showing a slightly-stale campaign is preferable to silence. + * When requestBannersRefresh is rate-limited by Braze, getBanner() still + * returns the last-cached banner. For each placement ID listed here as `true`, + * DCR will actively hide the banner rather than risk showing outdated content. + * Placement IDs omitted from this map (or set to `false`) fall through to + * their component's own fallback behaviour. * - * Default for new entries: false (permissive — show cached or fall back). + * This is the single place to change suppression behaviour for any placement. + * Default for new placements: omit the entry (treated as `false`). */ -const ISLAND_PLACEMENT_MAP: Record< - string, - { - /** The Braze Banner placement IDs this island is responsible for. */ - ids: BrazeBannersSystemPlacementId[]; - /** - * Whether to suppress rendering when the last refresh was rate-limited. - * See the ISLAND_PLACEMENT_MAP JSDoc above for the full contract. - */ - suppressOnStale: boolean; - } +const PLACEMENT_SUPPRESS_ON_STALE: Partial< + Record > = { // MRR placements: suppress on stale — avoid showing outdated campaigns // that could mislead readers or contradict their current eligibility. - StickyBottomBanner: { - ids: [BrazeBannersSystemPlacementId.Banner], - suppressOnStale: true, - }, - SlotBodyEnd: { - ids: [BrazeBannersSystemPlacementId.EndOfArticle], - suppressOnStale: true, - }, - // Feast placements: do NOT suppress on stale. FeastContextualNudge has a - // native "Download the app" card as a fallback — if the Braze banner is - // unavailable or stale, that fallback renders instead of a blank slot. - FeastContextualNudge: { - ids: [ - BrazeBannersSystemPlacementId.FeastContextualNudge1, - BrazeBannersSystemPlacementId.FeastContextualNudge2, - BrazeBannersSystemPlacementId.FeastContextualNudge3, - BrazeBannersSystemPlacementId.FeastContextualNudge4, - BrazeBannersSystemPlacementId.FeastContextualNudge5, - ], - suppressOnStale: false, - }, + [BrazeBannersSystemPlacementId.Banner]: true, + [BrazeBannersSystemPlacementId.EndOfArticle]: true, + // Feast placements: not suppressed — FeastContextualNudge falls back to + // its native "Download the app" card when no Braze banner is available. + // Set any of these to `true` to suppress that specific nudge on stale. + [BrazeBannersSystemPlacementId.FeastContextualNudge1]: false, + [BrazeBannersSystemPlacementId.FeastContextualNudge2]: false, + [BrazeBannersSystemPlacementId.FeastContextualNudge3]: false, + [BrazeBannersSystemPlacementId.FeastContextualNudge4]: false, + [BrazeBannersSystemPlacementId.FeastContextualNudge5]: false, }; /** @@ -181,23 +173,24 @@ const ISLAND_PLACEMENT_MAP: Record< * Only placements whose corresponding island is present are included. */ export function getPagePlacements(): BrazeBannersSystemPlacementId[] { - return Object.entries(ISLAND_PLACEMENT_MAP).flatMap( - ([islandName, { ids }]) => - document.querySelector(`gu-island[name="${islandName}"]`) - ? ids - : [], + return Object.entries(ISLAND_PLACEMENT_MAP).flatMap(([islandName, ids]) => + document.querySelector(`gu-island[name="${islandName}"]`) ? ids : [], ); } /** * Pre-computed set of placement IDs that opt-in to stale suppression. - * Derived once at module load time from ISLAND_PLACEMENT_MAP entries where - * suppressOnStale is true — avoids re-computing on every refreshBanners call. + * Derived once at module load time from PLACEMENT_SUPPRESS_ON_STALE entries + * where the value is true — avoids re-computing on every refreshBanners call. */ const STALE_SUPPRESSABLE_PLACEMENTS = new Set( - Object.values(ISLAND_PLACEMENT_MAP) - .filter(({ suppressOnStale }) => suppressOnStale) - .flatMap(({ ids }) => ids), + ( + Object.entries(PLACEMENT_SUPPRESS_ON_STALE) as Array< + [BrazeBannersSystemPlacementId, boolean] + > + ) + .filter(([, suppress]) => suppress) + .map(([id]) => id), ); /** From 212591f71bca89df607f7cc2c009ad8a2563ee77 Mon Sep 17 00:00:00 2001 From: andresilva-guardian Date: Tue, 30 Jun 2026 17:11:13 +0100 Subject: [PATCH 12/12] Refactor getAdjustToken function for cleaner token retrieval in FeastContextualNudge --- .../src/components/FeastContextualNudge.island.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx index 56626543be7..934f9b54b04 100644 --- a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx +++ b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx @@ -48,9 +48,12 @@ const darkVars = css` const FEAST_ADJUST_TOKEN_PROD = '20wmhy68'; const FEAST_ADJUST_TOKEN_CODE = '20o7ykck'; +const getAdjustToken = (stage: StageType): string => { + return stage === 'PROD' ? FEAST_ADJUST_TOKEN_PROD : FEAST_ADJUST_TOKEN_CODE; +}; + const buildFeastLink = (recipeId: string, stage: StageType): string => { - const token = - stage === 'PROD' ? FEAST_ADJUST_TOKEN_PROD : FEAST_ADJUST_TOKEN_CODE; + const token = getAdjustToken(stage); return `https://guardian-feast.go.link/recipe/${encodeURIComponent( recipeId, )}?adj_t=${encodeURIComponent(token)}`; @@ -226,6 +229,7 @@ export const FeastContextualNudge = ({ isDev, nudgeIndex, darkMode: darkModeAvailable, + adjustToken: getAdjustToken(stage), }} />