Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 105 additions & 8 deletions dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@ import type {
ReminderComponent,
} from '@guardian/support-dotcom-components/dist/shared/types/reminders';
import { useCallback, useEffect, useRef, useState } from 'react';
import { submitComponentEvent } from '../../client/ophan/ophan';
import { useConfig } from '../../components/ConfigContext';
import { isProd } from '../../components/marketing/lib/stage';
import type { StageType } from '../../types/config';
import type { TagType } from '../../types/tag';
import { getAuthState, getOptionsHeaders } from '../identity';
import type { CandidateConfig, CanShowResult } from '../messagePicker';
import { useAuthStatus } from '../useAuthStatus';
import { useIsInView } from '../useIsInView';
import type { BrazeInstance } from './initialiseBraze';
import { suppressForTaylorReport } from './taylorReport';

Expand Down Expand Up @@ -150,12 +153,14 @@ export function refreshBanners(braze: BrazeInstance): Promise<void> {
* Meta information required to display a Braze Banner.
*/
export type BrazeBannersSystemMeta = {
id: string;
braze: BrazeInstance;
banner: Banner;
};

/**
* Checks if a Braze Banner for the given placement ID can be shown.
* @param id Unique ID for the message candidate, used for logging and debugging
* @param braze Braze instance
* @param placementId Placement ID to check for a banner
* @param contentType Content type of the article
Expand All @@ -164,6 +169,7 @@ export type BrazeBannersSystemMeta = {
* @returns CanShowResult with the Banner meta if it can be shown
*/
export const canShowBrazeBannersSystem = async (
id: string,
braze: BrazeInstance | null,
placementId: BrazeBannersSystemPlacementId,
contentType: string,
Expand Down Expand Up @@ -228,6 +234,7 @@ export const canShowBrazeBannersSystem = async (
return {
show: true,
meta: {
id,
braze,
banner,
},
Expand Down Expand Up @@ -266,6 +273,7 @@ export const buildBrazeBannersSystemConfig = (
id,
canShow: () => {
return canShowBrazeBannersSystem(
id,
braze,
placementId,
contentType,
Expand Down Expand Up @@ -455,6 +463,11 @@ export const BrazeBannersSystemDisplay = ({
useState<string>('#ffffff');
const [wrapperModeForegroundColor, setWrapperModeForegroundColor] =
useState<string>('#000000');
const { renderingTarget } = useConfig();
const [hasBeenSeen, setNode] = useIsInView({
debounce: true,
threshold: 0,
});

/**
* Subscribes the user to a newsletter via the Identity API.
Expand Down Expand Up @@ -657,7 +670,19 @@ export const BrazeBannersSystemDisplay = ({
meta.braze.insertBanner(meta.banner, containerRef.current);

// CSS Checker
runCssCheckerOnBrazeBanner(meta);
const cssValidationResult = runCssCheckerOnBrazeBanner(meta);
if (!cssValidationResult) {
brazeBannersSystemLogger.warn(
'CSS validation failed for the Braze Banner. This may indicate broken styles. Check UI/UX ASAP!',
);
meta.braze.logCustomEvent(
'braze_banner_css_validation_failed',
{
placementId: meta.banner.placementId,
},
);
// We don't want to block the display of the banner if the CSS is broken, but we log it for awareness and action.
}
}
}, [showBanner, meta, meta.banner, meta.braze, setWrapperModeColors]);

Expand Down Expand Up @@ -729,6 +754,10 @@ export const BrazeBannersSystemDisplay = ({
});
break;
case BrazeBannersSystemMessageType.NewsletterSubscribe:
meta.braze.logBannerClick(
meta.banner,
'newsletter_subscribe_button',
);
const { newsletterId } = event.data;
if (newsletterId) {
void subscribeToNewsletter(newsletterId).then(
Expand All @@ -739,11 +768,23 @@ export const BrazeBannersSystemDisplay = ({
success,
},
);
meta.braze.logCustomEvent(
'braze_banner_newsletter_subscribe',
{
placementId: meta.banner.placementId,
newsletterId,
success,
},
);
},
);
}
break;
case BrazeBannersSystemMessageType.ReminderSubscribe:
meta.braze.logBannerClick(
meta.banner,
'reminder_subscribe_button',
);
const {
reminderPeriod,
reminderComponent,
Expand All @@ -761,6 +802,16 @@ export const BrazeBannersSystemDisplay = ({
success,
},
);
meta.braze.logCustomEvent(
'braze_banner_reminder_subscribe',
{
placementId: meta.banner.placementId,
reminderPeriod,
reminderComponent,
reminderOption,
success,
},
);
});
}
break;
Expand All @@ -771,8 +822,20 @@ export const BrazeBannersSystemDisplay = ({
);
break;
case BrazeBannersSystemMessageType.NavigateToUrl:
meta.braze.logBannerClick(
meta.banner,
'navigate_to_url_button',
);
const { url, target } = event.data;
if (url) {
meta.braze.logCustomEvent(
'braze_banner_navigate_to_url',
{
placementId: meta.banner.placementId,
url,
target,
},
);
if (target === 'blank') {
window.open(url, '_blank');
} else {
Expand All @@ -797,8 +860,11 @@ export const BrazeBannersSystemDisplay = ({
}
break;
case BrazeBannersSystemMessageType.DismissBanner:
// Remove the banner from the DOM
meta.braze.logBannerClick(meta.banner, 'dismiss_button');
dismissBanner();
meta.braze.logCustomEvent('braze_banner_dismissed', {
placementId: meta.banner.placementId,
});
break;
}
};
Expand All @@ -818,8 +884,38 @@ export const BrazeBannersSystemDisplay = ({
postMessageToBrazeBanner,
]);

// Log Impressions with Braze and Button Clicks with Ophan
// TODO
// Log Impressions when the banner is seen, using the hasBeenSeen value from the useIsInView hook
useEffect(() => {
if (hasBeenSeen) {
brazeBannersSystemLogger.info(
`Banner with placement ID "${meta.banner.placementId}" has been seen. Logging impression.`,
);

// Dispatch a custom event
document.dispatchEvent(
new CustomEvent('banner:open', {
detail: { bannerId: meta.id },
}),
);

// Log the impression with Braze
meta.braze.logBannerImpressions([meta.banner.placementId]);

// Log VIEW event with Ophan
void submitComponentEvent(
{
component: {
componentType: 'RETENTION_ENGAGEMENT_BANNER',
id:
meta.banner.getStringProperty('ophanComponentId') ??
meta.banner.placementId,
},
action: 'VIEW',
},
renderingTarget,
);
}
}, [hasBeenSeen, meta.banner, meta.id, meta.braze, renderingTarget]);

/**
* If showBanner is false, we return null to unmount the component and remove the banner from the DOM.
Expand All @@ -831,6 +927,7 @@ export const BrazeBannersSystemDisplay = ({

return (
<div
ref={setNode}
className="braze-banner"
style={{
minHeight,
Expand All @@ -841,9 +938,9 @@ export const BrazeBannersSystemDisplay = ({
max-height: 65svh;
border-top: 1px solid rgb(0, 0, 0);
background-color: ${wrapperModeBackgroundColor};
${until.phablet} {
border: none;
}
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: none;
`
: undefined
}
Expand Down Expand Up @@ -890,7 +987,7 @@ export const BrazeBannersSystemDisplay = ({
max-width: 660px;
grid-template:
'. .'
'copy-container close-button' / auto 0px;
'copy-container close-button' / 100% 0px;
}
`
: undefined
Expand Down
54 changes: 45 additions & 9 deletions dotcom-rendering/src/lib/braze/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,10 @@ Because marketing teams have styling flexibility, there is a risk of campaigns t

- Parses the banner's HTML/CSS.
- Validates that every CSS selector matches at least one element.
- Logs warnings (in development) if "dead" selectors are found.
This ensures broken creatives are caught during the QA process before launch.
- Logs warnings if "dead" selectors are found.
- On failure: emits a `brazeBannersSystemLogger.warn` and logs a Braze custom event `braze_banner_css_validation_failed` (with `placementId`) for Braze-side alerting. The banner is **not** blocked from rendering — it is shown regardless, but the failure is recorded for awareness and action.

This ensures broken creatives are caught during the QA process before launch.

#### B. JavaScript Isolation

Expand All @@ -103,7 +105,9 @@ To support more complex designs while maintaining consistency, the system suppor
- **Enabled via**: `wrapperModeEnabled` (Boolean) Key-Value pair.
- **Behavior**: When enabled, DCR applies specific styles to the **container** holding the Braze iframe, including:
- `max-height: 65svh` (prevents banners from taking over the full screen).
- `border-top: 1px solid black` (provides visual separation).
- `border-top: 1px solid black` (provides visual separation at all breakpoints — the previous phablet-level `border: none` override has been removed for consistency).
- `overflow-y: auto` and `overflow-x: hidden` (allow vertical scrolling within the banner container on small/portrait screens).
- `overscroll-behavior: none` (prevents scroll from propagating to the host page once the banner reaches its scroll boundary).
- Dynamic Background Color (see below).

#### Automatic Color Contrast
Expand All @@ -118,14 +122,46 @@ When providing a background color in Wrapper Mode, DCR automatically calculates

The system automatically reads specific Key-Value pairs from the Braze Campaign to configure the banner wrapper.

| Key | Type | Description |
| :--------------------------- | :------ | :----------------------------------------------------------------------------------------- |
| `minHeight` | String | Sets the CSS `min-height` of the container (e.g., "300px") to minimize layout shift (CLS). |
| `wrapperModeEnabled` | Boolean | Activates Wrapper Mode (see above). |
| `wrapperModeBackgroundColor` | String | Sets the background color of the wrapper and triggers the auto-contrast calculation. |
| Key | Type | Description |
| :--------------------------- | :------ | :-------------------------------------------------------------------------------------------------------------------- |
| `minHeight` | String | Sets the CSS `min-height` of the container (e.g., "300px") to minimize layout shift (CLS). |
| `wrapperModeEnabled` | Boolean | Activates Wrapper Mode (see above). |
| `wrapperModeBackgroundColor` | String | Sets the background color of the wrapper and triggers the auto-contrast calculation. |
| `ophanComponentId` | String | Overrides the Ophan component ID used when logging the `VIEW` impression event. Defaults to `placementId` if not set. |

_Custom keys can also be retrieved by the banner creative using the `BRAZE_BANNERS_SYSTEM:GET_SETTINGS_PROPERTY_VALUE` message._

### 8. Impression Tracking & Analytics

The system tracks when the banner enters the viewport and logs impressions to multiple destinations.

#### Visibility Detection

`BrazeBannersSystemDisplay` uses the `useIsInView` hook (backed by `IntersectionObserver`) to detect when the banner first becomes visible. Impression logging fires **once** per mount when `hasBeenSeen` becomes `true`.

#### On First View

When the banner enters the viewport for the first time, the following actions are performed in sequence:

1. **Logger**: `brazeBannersSystemLogger.info` records the event with the banner's placement ID.
2. **DOM Event**: A `banner:open` custom event is dispatched on `document`, carrying `{ bannerId: meta.id }`. This allows other parts of the page to react to the banner becoming visible.
3. **Braze Impression**: `meta.braze.logBannerImpressions([placementId])` notifies Braze that the banner was shown (used for frequency capping and campaign reporting).
4. **Ophan VIEW**: A `VIEW` component event is submitted via `submitComponentEvent` with `componentType: 'RETENTION_ENGAGEMENT_BANNER'`. The component ID is read from the `ophanComponentId` Key-Value pair, falling back to `placementId` if not set.

### 9. User Interaction Logging

All user interactions with the banner are tracked with both a Braze banner click (`logBannerClick`) and a Braze custom event (`logCustomEvent`) containing contextual metadata.

| Interaction | Braze Click Label | Braze Custom Event | Custom Event Properties |
| :------------------- | :---------------------------- | :----------------------------------- | :-------------------------------------------------------------------------------- |
| Newsletter Subscribe | `newsletter_subscribe_button` | `braze_banner_newsletter_subscribe` | `placementId`, `newsletterId`, `success` |
| Reminder Subscribe | `reminder_subscribe_button` | `braze_banner_reminder_subscribe` | `placementId`, `reminderPeriod`, `reminderComponent`, `reminderOption`, `success` |
| Navigate to URL | `navigate_to_url_button` | `braze_banner_navigate_to_url` | `placementId`, `url`, `target` |
| 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.

## Communication Protocol

The banner uses a `postMessage` protocol to interact with the host DCR page.
Expand Down Expand Up @@ -663,4 +699,4 @@ const brazeCandidate = buildBrazeBannersSystemConfig(
---

**Value Team**
_Last Updated: February 2026_
_Last Updated: March 2026_
Loading