diff --git a/dotcom-rendering/package.json b/dotcom-rendering/package.json
index c45dc366190..8dccd8978ef 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/lib/ArticleRenderer.test.tsx b/dotcom-rendering/src/lib/ArticleRenderer.test.tsx
index a9f2534f829..6d9e2693691 100644
--- a/dotcom-rendering/src/lib/ArticleRenderer.test.tsx
+++ b/dotcom-rendering/src/lib/ArticleRenderer.test.tsx
@@ -13,13 +13,23 @@ import {
type ArticleFormat,
Pillar,
} from './articleFormat';
-import { ArticleRenderer } from './ArticleRenderer';
+import { ArticleRenderer, getFeastNudgeIndex } from './ArticleRenderer';
// ── Mocks ─────────────────────────────────────────────────────────────────────
jest.mock('../components/FeastContextualNudge.island', () => ({
- 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 d7fd529642a..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[];
@@ -158,21 +185,11 @@ export const ArticleRenderer = ({
const result: (JSX.Element | null | undefined)[] = [...preSection];
- /**
- * Distribute up to 5 Braze placement slots evenly across all sections.
- * interval = ceil(sections.length / 5)
- * A section at 0-based index i gets nudgeIndex = (i+1)/interval
- * only when (i+1) is an exact multiple of interval (and ≤ 5).
- * All other sections get nudgeIndex = null (no nudge rendered).
- */
- const MAX_NUDGES = 5;
- const interval = Math.ceil(sections.length / MAX_NUDGES);
-
for (const section of sections) {
- const position = section.index + 1; // 1-based
- const nudgeIndex =
- position % interval === 0 ? position / interval : null;
-
+ const nudgeIndex = getFeastNudgeIndex(
+ section.index,
+ sections.length,
+ );
result.push(
{section.subheadingEl}
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..e2a6eb42123
--- /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.Banner;
+ 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(
+ expect.arrayContaining([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.EndOfArticle,
+ ] 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 b7871958656..4858b88824e 100644
--- a/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx
+++ b/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx
@@ -411,7 +411,7 @@ export const canShowBrazeBannersSystem = async (
* campaigns. Only placements with suppressOnStale: true in
* ISLAND_PLACEMENT_MAP can ever be stale (currently: Banner, EndOfArticle).
*/
- if (stalePlacements.has(placementId)) {
+ if (isPlacementStale(placementId)) {
brazeBannersSystemLogger.info(
`Placement "${placementId}" is stale (last refresh was rate-limited). Not showing banner.`,
);
@@ -661,6 +661,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] =
@@ -801,7 +802,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(
@@ -971,16 +975,18 @@ export const BrazeBannersSystemDisplay = ({
>,
) => {
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(
@@ -1113,9 +1119,7 @@ export const BrazeBannersSystemDisplay = ({
case BrazeBannersSystemMessageType.GetContext:
postMessageToBrazeBanner(
BrazeBannersSystemMessageType.GetContext,
- {
- context,
- },
+ { context },
);
break;
}
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..d8b02446e10
--- /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(
+ expect.arrayContaining(['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/pnpm-lock.yaml b/pnpm-lock.yaml
index 0b9e89d895a..f3122444735 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
@@ -1695,8 +1695,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==}
@@ -11358,7 +11358,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: