diff --git a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx
index bfee9dbd539..934f9b54b04 100644
--- a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx
+++ b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx
@@ -7,7 +7,13 @@ import {
} from '@guardian/source/foundations';
import { LinkButton } from '@guardian/source/react-components';
import { useEffect, useState } from 'react';
+import {
+ BrazeBannersSystemDisplay,
+ BrazeBannersSystemPlacementId,
+ isPlacementStale,
+} 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';
@@ -16,10 +22,8 @@ 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_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;
@@ -29,15 +33,13 @@ 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-text: ${FEAST_TEXT};
--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-text: ${FEAST_TEXT_DARK};
--feast-nudge-border: ${FEAST_BORDER_DARK};
`;
@@ -46,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)}`;
@@ -109,7 +114,7 @@ const buttonWrapperStyles = css`
const descriptionStyles = css`
${article15};
- color: var(--feast-nudge-subtext);
+ color: var(--feast-nudge-text);
b {
font-weight: bold;
}
@@ -122,6 +127,8 @@ type FeastContextualNudgeProps = {
recipeArticleTitle: string;
pageId: string;
isDev: boolean;
+ nudgeIndex: number;
+ idApiUrl: string | undefined;
};
/**
@@ -142,13 +149,17 @@ export const FeastContextualNudge = ({
recipeArticleTitle,
pageId,
isDev,
+ nudgeIndex,
+ idApiUrl,
}: FeastContextualNudgeProps) => {
const abTests = useAB();
const isVariant =
abTests?.isUserInTestGroup('feast-recipe-nudge-v2', 'variant-1') ??
false;
- const { darkModeAvailable } = useConfig();
+ const { darkModeAvailable, renderingTarget } = useConfig();
+
+ const { braze } = useBraze(idApiUrl ?? '', renderingTarget);
const [isStorybook, setIsStorybook] = useState(false);
useEffect(() => {
@@ -174,6 +185,58 @@ 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
+ ];
+
+ // Guard against stale placements: if the last requestBannersRefresh
+ // was rate-limited AND this placement has suppressOnStale: true in
+ // PLACEMENT_SUPPRESS_ON_STALE, skip getBanner() and fall through to
+ // the native nudge below.
+ //
+ // 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;
+
+ 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..1924a9eebe4 100644
--- a/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx
+++ b/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx
@@ -106,6 +106,120 @@ 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',
+}
+
+/**
+ * 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,
+ ],
+};
+
+/**
+ * Per-placement stale-suppression config.
+ *
+ * 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.
+ *
+ * This is the single place to change suppression behaviour for any placement.
+ * Default for new placements: omit the entry (treated as `false`).
+ */
+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.
+ [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,
+};
+
+/**
+ * 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 : [],
+ );
+}
+
+/**
+ * Pre-computed set of placement IDs that opt-in to stale suppression.
+ * 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.entries(PLACEMENT_SUPPRESS_ON_STALE) as Array<
+ [BrazeBannersSystemPlacementId, boolean]
+ >
+ )
+ .filter(([, suppress]) => suppress)
+ .map(([id]) => id),
+);
+
+/**
+ * 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);
}
/**
@@ -121,7 +235,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
@@ -139,14 +266,42 @@ export function refreshBanners(braze: BrazeInstance): Promise {
// Create the Braze Promise
const brazeRequest = new Promise((resolve) => {
braze.requestBannersRefresh(
- Object.values(BrazeBannersSystemPlacementId),
+ 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();
},
@@ -228,6 +383,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.
@@ -313,6 +482,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',
}
/**
@@ -447,16 +617,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'
@@ -745,6 +919,9 @@ export const BrazeBannersSystemDisplay = ({
| {
type: BrazeBannersSystemMessageType.DismissBanner;
}
+ | {
+ type: BrazeBannersSystemMessageType.GetContext;
+ }
>,
) => {
if (
@@ -886,6 +1063,14 @@ export const BrazeBannersSystemDisplay = ({
case BrazeBannersSystemMessageType.DismissBanner:
dismissBanner();
break;
+ case BrazeBannersSystemMessageType.GetContext:
+ postMessageToBrazeBanner(
+ BrazeBannersSystemMessageType.GetContext,
+ {
+ context,
+ },
+ );
+ break;
}
};
@@ -902,6 +1087,7 @@ export const BrazeBannersSystemDisplay = ({
createReminder,
dismissBanner,
postMessageToBrazeBanner,
+ context,
]);
// Log Impressions when the banner is seen, using the hasBeenSeen value from the useIsInView hook
diff --git a/dotcom-rendering/src/lib/braze/buildBrazeMessaging.ts b/dotcom-rendering/src/lib/braze/buildBrazeMessaging.ts
index d5341e41154..019632093cb 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';
@@ -132,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(
@@ -146,7 +150,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();