From 5afb1387cc9473f4293c590f9b2159252489cfcb Mon Sep 17 00:00:00 2001 From: Juarez Mota Date: Mon, 20 Jul 2026 13:57:45 +0100 Subject: [PATCH] Fix Braze banner dismissal and reentry; integrate Braze banners into Feast nudges - Guard against double-dismissal with hasDismissedRef; call braze.dismissBanner() on close so the SDK suppresses the banner natively - Track stale placements to prevent rendering cached banners after a failed or timed-out refresh; late successes mark placements fresh again - Only request placements that exist on the page via getPagePlacements() - Invert postMessage origin check for early return on invalid origins - Add GET_CONTEXT message type for banner creatives to read page context - Integrate BrazeBannersSystemDisplay into FeastContextualNudge with fallback - Assign up to 5 evenly distributed nudge indices in ArticleRenderer - Bump @braze/web-sdk from 6.5.0 to 6.9.0 - Add unit tests for all changes --- dotcom-rendering/package.json | 2 +- .../FeastContextualNudge.island.test.tsx | 113 +++++++++ .../FeastContextualNudge.island.tsx | 63 ++++- .../FeastContextualNudge.stories.tsx | 2 + .../src/lib/ArticleRenderer.test.tsx | 64 ++++- dotcom-rendering/src/lib/ArticleRenderer.tsx | 35 ++- .../src/lib/braze/BrazeBannersSystem.test.tsx | 239 ++++++++++++++++++ .../src/lib/braze/BrazeBannersSystem.tsx | 132 ++++++++-- dotcom-rendering/src/lib/braze/README.md | 35 +-- .../src/lib/braze/buildBrazeMessaging.test.ts | 102 ++++++++ .../src/lib/braze/buildBrazeMessaging.ts | 6 +- pnpm-lock.yaml | 10 +- 12 files changed, 752 insertions(+), 51 deletions(-) create mode 100644 dotcom-rendering/src/components/FeastContextualNudge.island.test.tsx create mode 100644 dotcom-rendering/src/lib/braze/BrazeBannersSystem.test.tsx create mode 100644 dotcom-rendering/src/lib/braze/buildBrazeMessaging.test.ts diff --git a/dotcom-rendering/package.json b/dotcom-rendering/package.json index 746ffe07177..42047654d1c 100644 --- a/dotcom-rendering/package.json +++ b/dotcom-rendering/package.json @@ -22,7 +22,7 @@ "dependencies": { "@aws-sdk/client-cloudwatch": "3.995.0", "@babel/helper-compilation-targets": "7.29.7", - "@braze/web-sdk": "6.5.0", + "@braze/web-sdk": "6.9.0", "@creditkarma/thrift-server-core": "1.0.4", "@emotion/cache": "11.14.0", "@emotion/react": "11.14.0", diff --git a/dotcom-rendering/src/components/FeastContextualNudge.island.test.tsx b/dotcom-rendering/src/components/FeastContextualNudge.island.test.tsx new file mode 100644 index 00000000000..8e4caa28796 --- /dev/null +++ b/dotcom-rendering/src/components/FeastContextualNudge.island.test.tsx @@ -0,0 +1,113 @@ +import { render, screen } from '@testing-library/react'; +import { isPlacementStale } from '../lib/braze/BrazeBannersSystem'; +import type { BrazeInstance } from '../lib/braze/initialiseBraze'; +import { useAB } from '../lib/useAB'; +import { useBraze } from '../lib/useBraze'; +import type { RecipeBlockElement } from '../types/content'; +import { ConfigProvider } from './ConfigContext'; +import { FeastContextualNudge } from './FeastContextualNudge.island'; + +jest.mock('../lib/useAB'); +jest.mock('../lib/useBraze'); +jest.mock('../lib/braze/BrazeBannersSystem', () => ({ + BrazeBannersSystemPlacementId: { + 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', + }, + isPlacementStale: jest.fn(), + BrazeBannersSystemDisplay: ({ meta }: { meta: { id: string } }) => ( +
+ ), +})); + +const recipe: RecipeBlockElement = { + _type: 'model.dotcomrendering.pageElements.RecipeBlockElement', + id: 'recipe-id', + title: 'Test recipe', +}; + +const braze = { + getBanner: jest.fn(), +} as unknown as BrazeInstance; + +const renderNudge = (idApiUrl: string | undefined) => + render( + + + , + ); + +describe('FeastContextualNudge Braze fallback', () => { + beforeEach(() => { + jest.mocked(useAB).mockReturnValue({ + isUserInTestGroup: () => true, + isUserInTest: () => true, + getParticipations: () => ({}), + trackABTests: () => ({}), + }); + jest.mocked(useBraze).mockReturnValue({ + braze, + brazeCards: undefined, + brazeMessages: undefined, + }); + jest.mocked(isPlacementStale).mockReturnValue(false); + jest.mocked(braze.getBanner).mockReset(); + }); + + it('renders the Braze banner for a fresh eligible placement', () => { + jest.mocked(braze.getBanner).mockReturnValue({} as never); + + renderNudge('https://id.test'); + + expect(screen.getByTestId('braze-feast-banner')).toHaveAttribute( + 'data-banner-id', + 'feast-contextual-nudge-1', + ); + expect(screen.queryByText('Download the app')).not.toBeInTheDocument(); + }); + + it('uses the native Feast card instead of a cached banner when stale', () => { + jest.mocked(isPlacementStale).mockReturnValue(true); + jest.mocked(braze.getBanner).mockReturnValue({} as never); + + renderNudge('https://id.test'); + + expect(screen.getByText('Download the app')).toBeInTheDocument(); + expect( + screen.queryByTestId('braze-feast-banner'), + ).not.toBeInTheDocument(); + expect(braze.getBanner).not.toHaveBeenCalled(); + }); + + it('uses the native Feast card when no banner is eligible', () => { + jest.mocked(braze.getBanner).mockReturnValue(null); + + renderNudge('https://id.test'); + + expect(screen.getByText('Download the app')).toBeInTheDocument(); + }); + + it('does not query Braze when the identity API URL is unavailable', () => { + renderNudge(undefined); + + expect(screen.getByText('Download the app')).toBeInTheDocument(); + expect(braze.getBanner).not.toHaveBeenCalled(); + }); +}); diff --git a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx index bfee9dbd539..8a368ace675 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'; @@ -46,9 +52,19 @@ const darkVars = css` const FEAST_ADJUST_TOKEN_PROD = '20wmhy68'; const FEAST_ADJUST_TOKEN_CODE = '20o7ykck'; +const FEAST_BRAZE_PLACEMENTS = [ + BrazeBannersSystemPlacementId.FeastContextualNudge1, + BrazeBannersSystemPlacementId.FeastContextualNudge2, + BrazeBannersSystemPlacementId.FeastContextualNudge3, + BrazeBannersSystemPlacementId.FeastContextualNudge4, + BrazeBannersSystemPlacementId.FeastContextualNudge5, +] as const; + +const getAdjustToken = (stage: StageType): string => + 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)}`; @@ -122,6 +138,8 @@ type FeastContextualNudgeProps = { recipeArticleTitle: string; pageId: string; isDev: boolean; + nudgeIndex: number; + idApiUrl?: string; }; /** @@ -142,13 +160,16 @@ 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 +195,42 @@ export const FeastContextualNudge = ({ if (!isVariant) return null; + const placementId = FEAST_BRAZE_PLACEMENTS[nudgeIndex - 1]; + const banner = + idApiUrl && placementId && !isPlacementStale(placementId) + ? braze?.getBanner(placementId) + : null; + + if (banner && braze && idApiUrl) { + return ( +
+ +
+ ); + } + return (
({ - FeastContextualNudge: ({ recipe }: { recipe: { id: string } }) => ( -
+ FeastContextualNudge: ({ + recipe, + nudgeIndex, + }: { + recipe: { id: string }; + nudgeIndex: number; + }) => ( +
), })); @@ -215,6 +225,40 @@ describe('ArticleRenderer — augmentedElements', () => { expect(nudges).toHaveLength(2); expect(nudges[0]).toHaveAttribute('data-recipe-id', 'recipe-001'); expect(nudges[1]).toHaveAttribute('data-recipe-id', 'recipe-002'); + expect(nudges[0]).toHaveAttribute('data-nudge-index', '1'); + expect(nudges[1]).toHaveAttribute('data-nudge-index', '2'); + }); + + it('distributes exactly five numbered placements across a long recipe article', () => { + const elements: FEElement[] = Array.from({ length: 10 }, (_, index) => [ + makeSubheading(`

Recipe ${index}

`, index), + makeRecipe(`recipe-${index}`), + ]).flat(); + + renderWithConfig( + , + ); + + const nudges = screen.getAllByTestId('feast-nudge'); + expect(nudges).toHaveLength(5); + expect(nudges.map((nudge) => nudge.dataset.recipeId)).toEqual([ + 'recipe-0', + 'recipe-2', + 'recipe-5', + 'recipe-7', + 'recipe-9', + ]); + expect(nudges.map((nudge) => nudge.dataset.nudgeIndex)).toEqual([ + '1', + '2', + '3', + '4', + '5', + ]); }); it('renders body elements within each section in order', () => { @@ -237,3 +281,17 @@ describe('ArticleRenderer — augmentedElements', () => { expect(screen.getByTestId('article-element-3')).toBeInTheDocument(); }); }); + +describe('getFeastNudgeIndex', () => { + it('assigns every section when there are at most five', () => { + expect([0, 1, 2].map((index) => getFeastNudgeIndex(index, 3))).toEqual([ + 1, 2, 3, + ]); + }); + + it('returns null for invalid section indexes', () => { + expect(getFeastNudgeIndex(-1, 3)).toBeNull(); + expect(getFeastNudgeIndex(3, 3)).toBeNull(); + expect(getFeastNudgeIndex(0, 0)).toBeNull(); + }); +}); diff --git a/dotcom-rendering/src/lib/ArticleRenderer.tsx b/dotcom-rendering/src/lib/ArticleRenderer.tsx index 74dc8581b64..d04336cef97 100644 --- a/dotcom-rendering/src/lib/ArticleRenderer.tsx +++ b/dotcom-rendering/src/lib/ArticleRenderer.tsx @@ -18,6 +18,33 @@ const commercialPosition = css` position: relative; `; +const MAX_FEAST_NUDGES = 5; + +/** + * Assigns up to five numbered Braze placements across recipe sections, + * including the first and last section when there are more than five. + */ +export const getFeastNudgeIndex = ( + sectionIndex: number, + sectionCount: number, +): number | null => { + if (sectionIndex < 0 || sectionIndex >= sectionCount || sectionCount < 1) { + return null; + } + + const nudgeCount = Math.min(MAX_FEAST_NUDGES, sectionCount); + if (nudgeCount === sectionCount) return sectionIndex + 1; + + for (let slot = 0; slot < nudgeCount; slot++) { + const targetIndex = Math.round( + (slot * (sectionCount - 1)) / (nudgeCount - 1), + ); + if (targetIndex === sectionIndex) return slot + 1; + } + + return null; +}; + type Props = { format: ArticleFormat; elements: FEElement[]; @@ -159,10 +186,14 @@ export const ArticleRenderer = ({ const result: (JSX.Element | null | undefined)[] = [...preSection]; for (const section of sections) { + const nudgeIndex = getFeastNudgeIndex( + section.index, + sections.length, + ); result.push( {section.subheadingEl} - {section.recipe && ( + {section.recipe && nudgeIndex !== null && ( )} diff --git a/dotcom-rendering/src/lib/braze/BrazeBannersSystem.test.tsx b/dotcom-rendering/src/lib/braze/BrazeBannersSystem.test.tsx new file mode 100644 index 00000000000..f61495e388e --- /dev/null +++ b/dotcom-rendering/src/lib/braze/BrazeBannersSystem.test.tsx @@ -0,0 +1,239 @@ +import type { Banner } from '@braze/web-sdk'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { ConfigProvider } from '../../components/ConfigContext'; +import { + BrazeBannersSystemDisplay, + BrazeBannersSystemPlacementId, + canShowBrazeBannersSystem, + getPagePlacements, + isPlacementStale, + refreshBanners, +} from './BrazeBannersSystem'; +import type { BrazeInstance } from './initialiseBraze'; + +jest.mock('../useAuthStatus', () => ({ + useAuthStatus: () => ({ kind: 'SignedOut' }), +})); + +jest.mock('../useIsInView', () => ({ + useIsInView: () => [false, jest.fn()], +})); + +jest.mock('../../client/ophan/ophan', () => ({ + submitComponentEvent: jest.fn(), +})); + +const makeBraze = (requestBannersRefresh = jest.fn()): BrazeInstance => + ({ requestBannersRefresh }) as unknown as BrazeInstance; + +const makeBanner = ( + placementId = BrazeBannersSystemPlacementId.Banner, +): Banner => + ({ + placementId, + html: '
', + properties: {}, + getStringProperty: jest.fn(() => null), + getBooleanProperty: jest.fn((key: string) => + key === 'wrapperModeEnabled' ? true : null, + ), + }) as unknown as Banner; + +describe('Braze Banner placement refreshes', () => { + afterEach(() => { + document.body.innerHTML = ''; + jest.useRealTimers(); + }); + + it('requests only placements represented by islands on the page', () => { + document.body.innerHTML = ` + + + `; + + expect(getPagePlacements()).toEqual([ + BrazeBannersSystemPlacementId.Banner, + BrazeBannersSystemPlacementId.FeastContextualNudge1, + BrazeBannersSystemPlacementId.FeastContextualNudge2, + BrazeBannersSystemPlacementId.FeastContextualNudge3, + BrazeBannersSystemPlacementId.FeastContextualNudge4, + BrazeBannersSystemPlacementId.FeastContextualNudge5, + ]); + }); + + it('distinguishes a successful refresh with no banner from a failed refresh', async () => { + const placement = BrazeBannersSystemPlacementId.FeastContextualNudge1; + const request = jest + .fn() + .mockImplementationOnce( + (_ids: string[], _success: () => void, error: () => void) => + error(), + ) + .mockImplementationOnce((_ids: string[], success: () => void) => + success(), + ); + const braze = makeBraze(request); + + await refreshBanners(braze, [placement]); + expect(isPlacementStale(placement)).toBe(true); + + await refreshBanners(braze, [placement]); + expect(isPlacementStale(placement)).toBe(false); + expect(request).toHaveBeenCalledWith( + [placement], + expect.any(Function), + expect.any(Function), + ); + }); + + it('marks both Feast and Designable placements stale when a refresh fails', async () => { + const placements = [ + BrazeBannersSystemPlacementId.Banner, + BrazeBannersSystemPlacementId.FeastContextualNudge2, + ] as const; + const braze = makeBraze( + jest.fn((_ids: string[], _success: () => void, error: () => void) => + error(), + ), + ); + + await refreshBanners(braze, [...placements]); + + expect(isPlacementStale(placements[0])).toBe(true); + expect(isPlacementStale(placements[1])).toBe(true); + }); + + it('marks a placement stale on timeout, then fresh after a late success', async () => { + jest.useFakeTimers(); + const placement = BrazeBannersSystemPlacementId.EndOfArticle; + let completeRefresh: (() => void) | undefined; + const braze = makeBraze( + jest.fn((_ids: string[], success: () => void) => { + completeRefresh = success; + }), + ); + + const refresh = refreshBanners(braze, [placement]); + jest.advanceTimersByTime(2000); + await refresh; + expect(isPlacementStale(placement)).toBe(true); + + completeRefresh?.(); + expect(isPlacementStale(placement)).toBe(false); + }); + + it('does not read a cached banner for a stale Designable placement', async () => { + const placement = BrazeBannersSystemPlacementId.Banner; + const getBanner = jest.fn(() => makeBanner(placement)); + const braze = { + ...makeBraze( + jest.fn( + (_ids: string[], _success: () => void, error: () => void) => + error(), + ), + ), + getBanner, + } as BrazeInstance; + await refreshBanners(braze, [placement]); + + await expect( + canShowBrazeBannersSystem( + 'test-banner', + braze, + placement, + 'Article', + false, + [], + ), + ).resolves.toEqual({ show: false }); + expect(getBanner).not.toHaveBeenCalled(); + }); +}); + +describe('Braze Banner dismissal', () => { + it('logs an SDK dismissal and existing analytics when the wrapper is closed', async () => { + const banner = makeBanner(); + const braze = { + insertBanner: jest.fn(), + dismissBanner: jest.fn(() => true), + logBannerClick: jest.fn(), + logCustomEvent: jest.fn(), + logBannerImpressions: jest.fn(), + } as unknown as BrazeInstance; + + render( + + + , + ); + + const closeButton = await screen.findByRole('button', { + name: 'Close banner', + }); + fireEvent.click(closeButton); + + expect(braze.dismissBanner).toHaveBeenCalledWith(banner); + expect(braze.logBannerClick).toHaveBeenCalledWith( + banner, + 'dismiss_button', + ); + expect(braze.logCustomEvent).toHaveBeenCalledWith( + 'braze_banner_dismissed', + { placementId: banner.placementId }, + ); + await waitFor(() => + expect( + screen.queryByRole('button', { name: 'Close banner' }), + ).not.toBeInTheDocument(), + ); + }); + + it('ignores dismissal messages from another origin', async () => { + const banner = makeBanner(); + const braze = { + insertBanner: jest.fn(), + dismissBanner: jest.fn(), + logBannerClick: jest.fn(), + logCustomEvent: jest.fn(), + logBannerImpressions: jest.fn(), + } as unknown as BrazeInstance; + + render( + + + , + ); + + await screen.findByRole('button', { name: 'Close banner' }); + window.dispatchEvent( + new MessageEvent('message', { + origin: 'https://attacker.example', + data: { type: 'BRAZE_BANNERS_SYSTEM:DISMISS_BANNER' }, + }), + ); + + expect(braze.dismissBanner).not.toHaveBeenCalled(); + }); +}); diff --git a/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx b/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx index ffc732ebe0e..17028e55267 100644 --- a/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx +++ b/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx @@ -106,8 +106,57 @@ 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', +} + +const BRAZE_MAX_PLACEMENTS_PER_REFRESH = 10; + +const ISLAND_PLACEMENT_MAP: Record = { + StickyBottomBanner: [BrazeBannersSystemPlacementId.Banner], + SlotBodyEnd: [BrazeBannersSystemPlacementId.EndOfArticle], + FeastContextualNudge: [ + BrazeBannersSystemPlacementId.FeastContextualNudge1, + BrazeBannersSystemPlacementId.FeastContextualNudge2, + BrazeBannersSystemPlacementId.FeastContextualNudge3, + BrazeBannersSystemPlacementId.FeastContextualNudge4, + BrazeBannersSystemPlacementId.FeastContextualNudge5, + ], +}; + +/** Returns only placements whose rendering islands exist on this page. */ +export function getPagePlacements(): BrazeBannersSystemPlacementId[] { + return Object.entries(ISLAND_PLACEMENT_MAP).flatMap(([islandName, ids]) => + document.querySelector(`gu-island[name="${islandName}"]`) ? ids : [], + ); } +/** + * Placements whose latest refresh failed or timed out. Braze can continue to + * return cached banners after a failed refresh, so consumers must not render + * them until a later successful refresh confirms current eligibility. + */ +const stalePlacements = new Set(); + +export function isPlacementStale(id: BrazeBannersSystemPlacementId): boolean { + return stalePlacements.has(id); +} + +const markPlacementsStale = ( + placements: BrazeBannersSystemPlacementId[], +): void => { + for (const placement of placements) stalePlacements.add(placement); +}; + +const markPlacementsFresh = ( + placements: BrazeBannersSystemPlacementId[], +): void => { + for (const placement of placements) stalePlacements.delete(placement); +}; + /** * Trigger a refresh of Braze Banners System banners * "Each call to requestBannersRefresh consumes one token. If you attempt a refresh @@ -121,39 +170,53 @@ 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 { let timeoutId: NodeJS.Timeout; + let settled = false; + + brazeBannersSystemLogger.info( + `Requesting ${placements.length} Braze Banner placement(s): ${placements.join(', ')}`, + ); + if (placements.length > BRAZE_MAX_PLACEMENTS_PER_REFRESH) { + brazeBannersSystemLogger.warn( + `Braze accepts at most ${BRAZE_MAX_PLACEMENTS_PER_REFRESH} placements per refresh; ${placements.length} were requested.`, + ); + } - // Create the Timeout Promise const timeout = new Promise((resolve) => { timeoutId = setTimeout(() => { + settled = true; + markPlacementsStale(placements); brazeBannersSystemLogger.warn( - '⏱️ Refresh timed out. Proceeding anyway...', + 'Refresh timed out. Placements will use their non-Braze fallback.', ); - // We can't cancel the Braze network request, - // but we can ensure we stop waiting for it. resolve(); }, 2000); }); - // Create the Braze Promise const brazeRequest = new Promise((resolve) => { braze.requestBannersRefresh( - Object.values(BrazeBannersSystemPlacementId), + placements, () => { - brazeBannersSystemLogger.info('✅ Refresh completed.'); - clearTimeout(timeoutId); // Cancel the timeout - resolve(); + markPlacementsFresh(placements); + brazeBannersSystemLogger.info('Refresh completed.'); + clearTimeout(timeoutId); + if (!settled) resolve(); }, () => { - brazeBannersSystemLogger.warn('⚠️ Refresh failed.'); - clearTimeout(timeoutId); // Cancel the timeout - resolve(); + markPlacementsStale(placements); + brazeBannersSystemLogger.warn( + 'Refresh failed. Placements will use their non-Braze fallback.', + ); + clearTimeout(timeoutId); + if (!settled) resolve(); }, ); }); - // Race them return Promise.race([brazeRequest, timeout]); } @@ -228,6 +291,13 @@ export const canShowBrazeBannersSystem = async ( return { show: false }; } + if (isPlacementStale(placementId)) { + brazeBannersSystemLogger.info( + `Placement "${placementId}" is stale. Not showing its cached 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 +383,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 +518,19 @@ 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 context Optional page context exposed to trusted banner code * @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' @@ -466,6 +540,7 @@ export const BrazeBannersSystemDisplay = ({ const [showBanner, setShowBanner] = useState(true); // Tracks whether banner:open has been dispatched so we only fire banner:close if the open event was sent first. const hasDispatchedOpenRef = useRef(false); + const hasDismissedRef = useRef(false); const [minHeight, setMinHeight] = useState('0px'); const [wrapperModeEnabled, setWrapperModeEnabled] = @@ -606,7 +681,10 @@ export const BrazeBannersSystemDisplay = ({ * commercial code can release the mobile sticky ad slot. */ const dismissBanner = useCallback(() => { + if (hasDismissedRef.current) return; + hasDismissedRef.current = true; setShowBanner(false); + meta.braze.dismissBanner(meta.banner); meta.braze.logBannerClick(meta.banner, 'dismiss_button'); if (hasDispatchedOpenRef.current) { document.dispatchEvent( @@ -745,19 +823,24 @@ export const BrazeBannersSystemDisplay = ({ | { type: BrazeBannersSystemMessageType.DismissBanner; } + | { + type: BrazeBannersSystemMessageType.GetContext; + } >, ) => { if ( - event.origin === window.location.origin && - Object.values(BrazeBannersSystemMessageType).includes( - event.data.type, + event.origin !== window.location.origin || + !Object.values(BrazeBannersSystemMessageType).includes( + event.data?.type, ) ) { - brazeBannersSystemLogger.log( - '📥 Received message from Braze Banner:', - event.data, - ); + return; } + + brazeBannersSystemLogger.log( + '📥 Received message from Braze Banner:', + event.data, + ); switch (event.data.type) { case BrazeBannersSystemMessageType.GetAuthStatus: postMessageToBrazeBanner( @@ -886,6 +969,12 @@ export const BrazeBannersSystemDisplay = ({ case BrazeBannersSystemMessageType.DismissBanner: dismissBanner(); break; + case BrazeBannersSystemMessageType.GetContext: + postMessageToBrazeBanner( + BrazeBannersSystemMessageType.GetContext, + { context }, + ); + break; } }; @@ -902,6 +991,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/README.md b/dotcom-rendering/src/lib/braze/README.md index 8385fd95698..5d6d0ee3c72 100644 --- a/dotcom-rendering/src/lib/braze/README.md +++ b/dotcom-rendering/src/lib/braze/README.md @@ -35,10 +35,11 @@ The system is encapsulated primarily in `BrazeBannersSystem.tsx` and interacts w We map DCR-specific slots to Braze Placement IDs. This abstraction allows us to change the underlying ID without refactoring the components. -| DCR Placement ID | Description | -| :------------------------------------------- | :------------------------------------------------------------------ | -| `BrazeBannersSystemPlacementId.EndOfArticle` | Appears at the bottom of the article body (Epic slot). | -| `BrazeBannersSystemPlacementId.Banner` | Appears fixed at the bottom of the viewport (Sticky Bottom Banner). | +| DCR Placement ID | Description | +| :-------------------------------------------------------- | :------------------------------------------------------------------ | +| `BrazeBannersSystemPlacementId.EndOfArticle` | Appears at the bottom of the article body (Epic slot). | +| `BrazeBannersSystemPlacementId.Banner` | Appears fixed at the bottom of the viewport (Sticky Bottom Banner). | +| `BrazeBannersSystemPlacementId.FeastContextualNudge1`–`5` | Replaces up to five evenly distributed Feast contextual nudges. | ### 2. Concurrency & Slot Priority @@ -96,7 +97,8 @@ Braze enforces a "Token Bucket" algorithm for refreshing banners (re-checking el - **Capacity**: 5 tokens per session. - **Refill**: 1 token every 3 minutes. -- **Implementation**: The `refreshBanners()` function creates a race condition with a timeout. If the network is slow or tokens are empty, DCR proceeds without blocking the render. +- **Page-aware requests**: DCR requests only placements whose `gu-island` exists on the current page. +- **Implementation**: The `refreshBanners()` function creates a race condition with a timeout. A failed or timed-out placement is marked stale so cached eligibility is not rendered. Designable slots continue through message-picker fallbacks; Feast slots render the native Feast card. A later successful refresh marks the placements fresh again. ### 6. Wrapper Mode & Styling @@ -160,7 +162,7 @@ All user interactions with the banner are tracked with both a Braze banner click | Dismiss Banner | `dismiss_button` | `braze_banner_dismissed` | `placementId` | | CSS Validation Fail | — | `braze_banner_css_validation_failed` | `placementId` | -> The Braze click (`logBannerClick`) feeds native Braze campaign reports, while the custom event (`logCustomEvent`) provides granular analytics available in Braze Data Export and BigQuery. +> Dismissal also calls the Web SDK's `dismissBanner(banner)`. This records Braze's native dismissal and suppresses that Banner for the user. The Braze click and custom event remain for the existing reporting streams. ## Communication Protocol @@ -168,15 +170,16 @@ The banner uses a `postMessage` protocol to interact with the host DCR page. ### Supported Message Types -| Message Type | Function | -| :------------------------------------------------- | :---------------------------------------------------- | -| `BRAZE_BANNERS_SYSTEM:GET_AUTH_STATUS` | Checks if user is `SignedIn`. | -| `BRAZE_BANNERS_SYSTEM:GET_EMAIL_ADDRESS` | Requests email (if signed in). | -| `BRAZE_BANNERS_SYSTEM:NEWSLETTER_SUBSCRIBE` | Subscribes to a newsletter ID. | -| `BRAZE_BANNERS_SYSTEM:REMINDER_SUBSCRIBE` | Creates a one-off reminder for contribution requests. | -| `BRAZE_BANNERS_SYSTEM:NAVIGATE_TO_URL` | Navigates the host page to a URL. | -| `BRAZE_BANNERS_SYSTEM:DISMISS_BANNER` | Removes the banner from the DOM. | -| `BRAZE_BANNERS_SYSTEM:GET_SETTINGS_PROPERTY_VALUE` | Reads Key-Value pairs from the Campaign config. | +| Message Type | Function | +| :------------------------------------------------- | :----------------------------------------------------- | +| `BRAZE_BANNERS_SYSTEM:GET_AUTH_STATUS` | Checks if user is `SignedIn`. | +| `BRAZE_BANNERS_SYSTEM:GET_EMAIL_ADDRESS` | Requests email (if signed in). | +| `BRAZE_BANNERS_SYSTEM:NEWSLETTER_SUBSCRIBE` | Subscribes to a newsletter ID. | +| `BRAZE_BANNERS_SYSTEM:REMINDER_SUBSCRIBE` | Creates a one-off reminder for contribution requests. | +| `BRAZE_BANNERS_SYSTEM:NAVIGATE_TO_URL` | Navigates the host page to a URL. | +| `BRAZE_BANNERS_SYSTEM:DISMISS_BANNER` | Removes the banner from the DOM. | +| `BRAZE_BANNERS_SYSTEM:GET_CONTEXT` | Reads the page context supplied by the host component. | +| `BRAZE_BANNERS_SYSTEM:GET_SETTINGS_PROPERTY_VALUE` | Reads Key-Value pairs from the Campaign config. | ### Feature Details @@ -295,7 +298,7 @@ Removes the banner from the DOM. **Response**: None (the banner is immediately removed) -**Description**: Programmatically dismisses the banner, removing it from the page. Use this when the user clicks a close button or completes an action that should hide the banner. +**Description**: Programmatically dismisses the banner, removes it from the page, and calls Braze's native `dismissBanner` API so the Banner is suppressed for that user. Duplicate SDK dismissals are safe and ignored by Braze. --- diff --git a/dotcom-rendering/src/lib/braze/buildBrazeMessaging.test.ts b/dotcom-rendering/src/lib/braze/buildBrazeMessaging.test.ts new file mode 100644 index 00000000000..4f0df47750d --- /dev/null +++ b/dotcom-rendering/src/lib/braze/buildBrazeMessaging.test.ts @@ -0,0 +1,102 @@ +import { getOphan } from '../../client/ophan/ophan'; +import { buildBrazeMessaging } from './buildBrazeMessaging'; +import { checkBrazeDependencies } from './checkBrazeDependencies'; +import type { BrazeInstance } from './initialiseBraze'; +import { getInitialisedBraze } from './initialiseBraze'; + +jest.mock('@guardian/libs', () => ({ + ...jest.requireActual('@guardian/libs'), + log: jest.fn(), + startPerformanceMeasure: () => ({ endPerformanceMeasure: () => 0 }), + storage: { local: { isAvailable: () => true } }, +})); + +jest.mock('@guardian/braze-components/logic', () => ({ + BrazeCards: jest.fn().mockImplementation(() => ({})), + BrazeMessages: jest.fn().mockImplementation(() => ({})), + canRenderBrazeMsg: jest.fn(), + LocalMessageCache: { clear: jest.fn() }, + NullBrazeCards: jest.fn().mockImplementation(() => ({})), + NullBrazeMessages: jest.fn().mockImplementation(() => ({})), +})); + +jest.mock('../../client/ophan/ophan', () => ({ + getOphan: jest.fn(), +})); + +jest.mock('../hasCurrentBrazeUser', () => ({ + clearHasCurrentBrazeUser: jest.fn(), + hasCurrentBrazeUser: jest.fn(() => false), + setHasCurrentBrazeUser: jest.fn(), +})); + +jest.mock('./checkBrazeDependencies'); +jest.mock('./initialiseBraze', () => ({ + getInitialisedBraze: jest.fn(), +})); + +const dependencyResult = { + isSuccessful: true as const, + data: { + apiKey: 'api-key', + brazeUuid: 'braze-user', + consent: true, + brazeSwitch: true, + }, +}; + +const makeBraze = (events: string[]): BrazeInstance => + ({ + changeUser: jest.fn(() => events.push('changeUser')), + requestBannersRefresh: jest.fn( + (_placements: string[], success: () => void) => { + events.push('requestBannersRefresh'); + success(); + }, + ), + openSession: jest.fn(() => events.push('openSession')), + subscribeToBannersUpdates: jest.fn(() => 'subscription-id'), + }) as unknown as BrazeInstance; + +describe('buildBrazeMessaging banner initialisation', () => { + beforeEach(() => { + document.body.innerHTML = ''; + jest.mocked(checkBrazeDependencies).mockResolvedValue(dependencyResult); + jest.mocked(getOphan).mockResolvedValue({ + record: jest.fn(), + } as never); + window.guardian.config.switches.brazeContentCards = false; + }); + + it('orders changeUser, banner refresh, then openSession', async () => { + document.body.innerHTML = + ''; + const events: string[] = []; + const braze = makeBraze(events); + jest.mocked(getInitialisedBraze).mockResolvedValue(braze); + + await buildBrazeMessaging('https://id.test', true, 'Web'); + + expect(events).toEqual([ + 'changeUser', + 'requestBannersRefresh', + 'openSession', + ]); + expect(braze.requestBannersRefresh).toHaveBeenCalledWith( + ['dotcom-rendering_banner'], + expect.any(Function), + expect.any(Function), + ); + }); + + it('does not spend a refresh token when the page has no banner islands', async () => { + const events: string[] = []; + const braze = makeBraze(events); + jest.mocked(getInitialisedBraze).mockResolvedValue(braze); + + await buildBrazeMessaging('https://id.test', true, 'Web'); + + expect(events).toEqual(['changeUser', 'openSession']); + expect(braze.requestBannersRefresh).not.toHaveBeenCalled(); + }); +}); 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(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b793f11b3bf..f9b73b274c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -297,8 +297,8 @@ importers: specifier: 7.29.7 version: 7.29.7 '@braze/web-sdk': - specifier: 6.5.0 - version: 6.5.0 + specifier: 6.9.0 + version: 6.9.0 '@creditkarma/thrift-server-core': specifier: 1.0.4 version: 1.0.4 @@ -1697,8 +1697,8 @@ packages: resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true - '@braze/web-sdk@6.5.0': - resolution: {integrity: sha512-MdfwZn+tfe7+tR8Ir5468/njQDGhgVB9tBthq7gwSETsjv6Q+Hw2bXiQy3scDcACI2RLqiq+dNXKh6fD+pN4/Q==} + '@braze/web-sdk@6.9.0': + resolution: {integrity: sha512-JiYpRJsx/qJT3VFunt4i2aiSo2jfwT1z5KCUM3Mytah3F6VMdGDW3CLUo+aU12QRSTkdbsHezD0ZRxOJEgtFmA==} '@cacheable/memory@2.0.8': resolution: {integrity: sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw==} @@ -11376,7 +11376,7 @@ snapshots: dependencies: css-tree: 3.2.1 - '@braze/web-sdk@6.5.0': {} + '@braze/web-sdk@6.9.0': {} '@cacheable/memory@2.0.8': dependencies: