Skip to content

Commit 3edbc34

Browse files
Feast Contextual Nudge Braze Banners (#16254)
* Add Braze banner integration to Feast contextual nudge component * Add nudgeIndex and idApiUrl to FeastContextualNudge story args * Add GetContext message type and context parameter to Braze Banners System * Add context prop to FeastContextualNudge component * Remove unused text color variables from FeastContextualNudge component * Refactor FeastContextualNudge component to replace subtext variables with text variables * Wrap BrazeBannersSystemDisplay in a div with aria-description for accessibility and a margin * Add placement management for Braze Banners System and update refresh logic * Enhance logging for Braze banners updates to provide clearer information * Add stale placement handling for Braze banners to improve fallback logic * Refactor Braze Banners System to implement per-placement stale suppression configuration * Refactor getAdjustToken function for cleaner token retrieval in FeastContextualNudge * Add nudgeMinHeightStyles to prevent content shift during Braze banner loading * Build Feast deep-link URL using the correct Adjust token in FeastContextualNudge * Temporarily force nudge rendering for testing purposes in FeastContextualNudge * Revert "Build Feast deep-link URL using the correct Adjust token in FeastContextualNudge" This reverts commit e279321. * Add support for requesting all known Braze placements in refreshBanners function * Log CLICK events with Ophan for app download and Braze Banner navigation * Remove Feast contextual nudge AB test and related mocks from components and stories * fix: ensure newline at end of file in abTests.ts
1 parent daedb15 commit 3edbc34

7 files changed

Lines changed: 398 additions & 97 deletions

File tree

ab-testing/config/abTests.ts

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -144,19 +144,6 @@ const ABTests: ABTest[] = [
144144
groups: ["control", "variant"],
145145
shouldForceMetricsCollection: false,
146146
},
147-
{
148-
name: "feast-recipe-nudge-v2",
149-
description:
150-
"Measures the impact of showing the Feast contextual nudge on recipe article pages",
151-
owners: ["feast@theguardian.com"],
152-
status: "ON",
153-
expirationDate: "2027-01-01",
154-
type: "client",
155-
audienceSize: 1,
156-
audienceSpace: "B",
157-
groups: ["control", "variant-1"],
158-
shouldForceMetricsCollection: false,
159-
},
160147
{
161148
name: "webx-monitor-group-contamination",
162149
description:

dotcom-rendering/src/components/FeastContextualNudge.island.tsx

Lines changed: 132 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@ import {
77
} from '@guardian/source/foundations';
88
import { LinkButton } from '@guardian/source/react-components';
99
import { useEffect, useState } from 'react';
10-
import { useAB } from '../lib/useAB';
10+
import { submitComponentEvent } from '../client/ophan/ophan';
11+
import {
12+
BrazeBannersSystemDisplay,
13+
BrazeBannersSystemPlacementId,
14+
isPlacementStale,
15+
} from '../lib/braze/BrazeBannersSystem';
16+
import { useBraze } from '../lib/useBraze';
1117
import type { StageType } from '../types/config';
1218
import type { RecipeBlockElement } from '../types/content';
1319
import { useConfig } from './ConfigContext';
@@ -16,10 +22,8 @@ import { useConfig } from './ConfigContext';
1622

1723
const FEAST_BG = '#F3F3E9';
1824
const FEAST_BG_DARK = '#2B2B26';
19-
const FEAST_TEXT = sourcePalette.neutral[10];
20-
const FEAST_TEXT_DARK = sourcePalette.neutral[100];
21-
const FEAST_SUBTEXT = sourcePalette.neutral[20];
22-
const FEAST_SUBTEXT_DARK = sourcePalette.neutral[93];
25+
const FEAST_TEXT = sourcePalette.neutral[20];
26+
const FEAST_TEXT_DARK = sourcePalette.neutral[93];
2327
const FEAST_GREEN = '#68773C';
2428
const FEAST_GREEN_HOVER = '#4d5c2b';
2529
const FEAST_BORDER = FEAST_GREEN;
@@ -29,15 +33,13 @@ const FEAST_BORDER_DARK = FEAST_GREEN;
2933

3034
const lightVars = css`
3135
--feast-nudge-bg: ${FEAST_BG};
32-
--feast-nudge-heading: ${FEAST_TEXT};
33-
--feast-nudge-subtext: ${FEAST_SUBTEXT};
36+
--feast-nudge-text: ${FEAST_TEXT};
3437
--feast-nudge-border: ${FEAST_BORDER};
3538
`;
3639

3740
const darkVars = css`
3841
--feast-nudge-bg: ${FEAST_BG_DARK};
39-
--feast-nudge-heading: ${FEAST_TEXT_DARK};
40-
--feast-nudge-subtext: ${FEAST_SUBTEXT_DARK};
42+
--feast-nudge-text: ${FEAST_TEXT_DARK};
4143
--feast-nudge-border: ${FEAST_BORDER_DARK};
4244
`;
4345

@@ -46,9 +48,12 @@ const darkVars = css`
4648
const FEAST_ADJUST_TOKEN_PROD = '20wmhy68';
4749
const FEAST_ADJUST_TOKEN_CODE = '20o7ykck';
4850

51+
const getAdjustToken = (stage: StageType): string => {
52+
return stage === 'PROD' ? FEAST_ADJUST_TOKEN_PROD : FEAST_ADJUST_TOKEN_CODE;
53+
};
54+
4955
const buildFeastLink = (recipeId: string, stage: StageType): string => {
50-
const token =
51-
stage === 'PROD' ? FEAST_ADJUST_TOKEN_PROD : FEAST_ADJUST_TOKEN_CODE;
56+
const token = getAdjustToken(stage);
5257
return `https://guardian-feast.go.link/recipe/${encodeURIComponent(
5358
recipeId,
5459
)}?adj_t=${encodeURIComponent(token)}`;
@@ -77,6 +82,40 @@ const cardGridStyles = css`
7782
}
7883
`;
7984

85+
// ── CLS mitigation ────────────────────────────────────────────────────────────
86+
87+
/**
88+
* Reserves vertical space for the nudge so that the async swap between the
89+
* native fallback card and a Braze banner (see the `useBraze` fetch below)
90+
* doesn't shift surrounding content once the placement is already visible to
91+
* the reader.
92+
*
93+
* Values are the rendered height of the native fallback card (fixed copy, so
94+
* the height is deterministic), measured via Storybook at each named
95+
* breakpoint where the description text wraps to a different number of
96+
* lines:
97+
* - < mobileMedium (375px): 3 lines → 111px
98+
* - mobileMedium–phablet (375–659px): 2 lines → 90px
99+
* - >= phablet (660px): 1 line → 69px
100+
*
101+
* A small buffer is added to absorb minor font-metric differences (e.g.
102+
* webfont vs. fallback font during load).
103+
*
104+
* This only guarantees no shift when a Braze banner is the same height or
105+
* shorter. Braze banners can set their own `minHeight` custom property (see
106+
* `BrazeBannersSystem.tsx`) to match or exceed these values so their content
107+
* doesn't overflow the reserved space either.
108+
*/
109+
const nudgeMinHeightStyles = css`
110+
min-height: 112px;
111+
${from.mobileMedium} {
112+
min-height: 92px;
113+
}
114+
${from.phablet} {
115+
min-height: 72px;
116+
}
117+
`;
118+
80119
// ── Card styles ───────────────────────────────────────────────────────────────
81120

82121
const showcaseCardStyles = css`
@@ -109,7 +148,7 @@ const buttonWrapperStyles = css`
109148

110149
const descriptionStyles = css`
111150
${article15};
112-
color: var(--feast-nudge-subtext);
151+
color: var(--feast-nudge-text);
113152
b {
114153
font-weight: bold;
115154
}
@@ -122,6 +161,8 @@ type FeastContextualNudgeProps = {
122161
recipeArticleTitle: string;
123162
pageId: string;
124163
isDev: boolean;
164+
nudgeIndex: number;
165+
idApiUrl: string | undefined;
125166
};
126167

127168
/**
@@ -142,13 +183,12 @@ export const FeastContextualNudge = ({
142183
recipeArticleTitle,
143184
pageId,
144185
isDev,
186+
nudgeIndex,
187+
idApiUrl,
145188
}: FeastContextualNudgeProps) => {
146-
const abTests = useAB();
147-
const isVariant =
148-
abTests?.isUserInTestGroup('feast-recipe-nudge-v2', 'variant-1') ??
149-
false;
189+
const { darkModeAvailable, renderingTarget } = useConfig();
150190

151-
const { darkModeAvailable } = useConfig();
191+
const { braze } = useBraze(idApiUrl ?? '', renderingTarget);
152192

153193
const [isStorybook, setIsStorybook] = useState(false);
154194
useEffect(() => {
@@ -164,15 +204,85 @@ export const FeastContextualNudge = ({
164204
const title = recipe.title ?? recipeArticleTitle;
165205
const feastId = recipe.id;
166206

207+
/**
208+
* Logs a CLICK event with Ophan when the reader taps the native
209+
* (non-Braze) "Download the app" install button.
210+
*/
211+
const handleDownloadClick = () => {
212+
void submitComponentEvent(
213+
{
214+
component: {
215+
componentType: 'RETENTION_ENGAGEMENT_BANNER',
216+
id: `feast-contextual-nudge-${nudgeIndex}`,
217+
},
218+
action: 'CLICK',
219+
},
220+
renderingTarget,
221+
);
222+
};
223+
167224
useEffect(() => {
168-
if (isDev && isVariant) {
225+
if (isDev) {
169226
console.log(
170227
`Contextual nudge for the Feast app, related to the recipe: ${title}. (id: ${feastId}; pageId: ${pageId})`,
171228
);
172229
}
173-
}, [feastId, title, pageId, isDev, isVariant]);
230+
}, [feastId, title, pageId, isDev]);
231+
232+
// If idApiUrl is defined and Braze has a banner for this placement slot,
233+
// render the Braze banner instead of the native nudge.
234+
if (idApiUrl !== undefined) {
235+
const placementId =
236+
BrazeBannersSystemPlacementId[
237+
`FeastContextualNudge${nudgeIndex}` as keyof typeof BrazeBannersSystemPlacementId
238+
];
239+
240+
// Guard against stale placements: if the last requestBannersRefresh
241+
// was rate-limited AND this placement has suppressOnStale: true in
242+
// PLACEMENT_SUPPRESS_ON_STALE, skip getBanner() and fall through to
243+
// the native nudge below.
244+
//
245+
// Each FeastContextualNudge placement ID has its own entry in
246+
// PLACEMENT_SUPPRESS_ON_STALE — change any individual one to `true`
247+
// to suppress that specific nudge on a failed refresh.
248+
const banner = !isPlacementStale(placementId)
249+
? (braze?.getBanner(placementId) ?? null)
250+
: null;
174251

175-
if (!isVariant) return null;
252+
if (banner && braze) {
253+
return (
254+
<div
255+
aria-description={`Open the recipe ${title} in the Feast app`}
256+
data-component="feast-contextual-nudge"
257+
css={[
258+
nudgeMinHeightStyles,
259+
css`
260+
margin: ${space[2]}px 0;
261+
`,
262+
]}
263+
>
264+
<BrazeBannersSystemDisplay
265+
meta={{
266+
id: `feast-contextual-nudge-${nudgeIndex}`,
267+
braze,
268+
banner,
269+
}}
270+
idApiUrl={idApiUrl}
271+
stage={stage}
272+
context={{
273+
recipe,
274+
recipeArticleTitle,
275+
pageId,
276+
isDev,
277+
nudgeIndex,
278+
darkMode: darkModeAvailable,
279+
adjustToken: getAdjustToken(stage),
280+
}}
281+
/>
282+
</div>
283+
);
284+
}
285+
}
176286

177287
return (
178288
<div
@@ -181,6 +291,7 @@ export const FeastContextualNudge = ({
181291
css={[
182292
showcaseCardStyles,
183293
cardGridStyles,
294+
nudgeMinHeightStyles,
184295
darkModeAvailable &&
185296
css`
186297
@media (prefers-color-scheme: dark) {
@@ -216,6 +327,7 @@ export const FeastContextualNudge = ({
216327
rel="noreferrer"
217328
theme={primaryCtaTheme}
218329
data-ignore="global-link-styling"
330+
onClick={handleDownloadClick}
219331
>
220332
Download the app
221333
</LinkButton>
Lines changed: 3 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,9 @@
11
import type { Meta, StoryObj } from '@storybook/react-webpack5';
2-
import { mocked } from 'storybook/test';
32
import { darkDecorator } from '../../.storybook/decorators/themeDecorator';
43
import { ArticleDesign, ArticleDisplay, Pillar } from '../lib/articleFormat';
5-
import { useAB } from '../lib/useAB';
64
import type { RecipeBlockElement } from '../types/content';
75
import { FeastContextualNudge } from './FeastContextualNudge.island';
86

9-
const mockBetaABVariant1 = () => {
10-
mocked(useAB).mockReturnValue({
11-
isUserInTestGroup: (_testId: string, group: string) =>
12-
group === 'variant-1',
13-
isUserInTest: () => true,
14-
getParticipations: () => ({}),
15-
trackABTests: () => ({}),
16-
});
17-
};
18-
19-
const mockBetaABControl = () => {
20-
mocked(useAB).mockReturnValue({
21-
isUserInTestGroup: (_testId: string, group: string) =>
22-
group === 'control',
23-
isUserInTest: () => true,
24-
getParticipations: () => ({}),
25-
trackABTests: () => ({}),
26-
});
27-
};
28-
297
const recipeFormat = {
308
design: ArticleDesign.Recipe,
319
display: ArticleDisplay.Standard,
@@ -53,6 +31,8 @@ const meta = {
5331
recipeArticleTitle: "Meera Sodha's spring onion pancakes",
5432
recipe: mockRecipe,
5533
isDev: true,
34+
nudgeIndex: 1,
35+
idApiUrl: undefined,
5636
},
5737
parameters: {
5838
chromatic: { viewports: [375, 740, 980] },
@@ -63,23 +43,9 @@ export default meta;
6343
type Story = StoryObj<typeof meta>;
6444

6545
/** Default — recipe name + CTAs */
66-
export const Default: Story = {
67-
beforeEach() {
68-
mockBetaABVariant1();
69-
},
70-
};
46+
export const Default: Story = {};
7147

7248
/** Dark mode */
7349
export const DefaultDark: Story = {
74-
beforeEach() {
75-
mockBetaABVariant1();
76-
},
7750
decorators: [darkDecorator([recipeFormat])],
7851
};
79-
80-
/** Control — nudge is not shown */
81-
export const Control: Story = {
82-
beforeEach() {
83-
mockBetaABControl();
84-
},
85-
};

dotcom-rendering/src/layouts/DecideLayout.stories.tsx

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { isObject } from '@guardian/libs';
22
import { breakpoints } from '@guardian/source/foundations';
33
import type { Decorator, StoryObj } from '@storybook/react-webpack5';
44
import { useEffect } from 'react';
5-
import { mocked } from 'storybook/test';
65
import { colourSchemeDecorator } from '../../.storybook/decorators/themeDecorator';
76
import { AffiliateProductShowcase as AffiliateProductShowcaseFixture } from '../../fixtures/generated/fe-articles/AffiliateProductShowcase';
87
import { AffiliateProductStandard as AffiliateProductStandardFixture } from '../../fixtures/generated/fe-articles/AffiliateProductStandard';
@@ -26,7 +25,6 @@ import {
2625
Pillar,
2726
} from '../lib/articleFormat';
2827
import { getCurrentPillar } from '../lib/layoutHelpers';
29-
import { useAB } from '../lib/useAB';
3028
import { extractNAV } from '../model/extract-nav';
3129
import { type Article, enhanceArticleType } from '../types/article';
3230
import { DecideLayout, type Props as DecideLayoutProps } from './DecideLayout';
@@ -294,20 +292,7 @@ const recipeStandardLifestyleWebFixture: Article = enhanceArticleType(
294292
'Web',
295293
);
296294

297-
const mockBetaFeastContextualNudgeABVariant1 = () => {
298-
mocked(useAB).mockReturnValue({
299-
isUserInTestGroup: (_testId: string, group: string) =>
300-
group === 'variant-1',
301-
isUserInTest: () => true,
302-
getParticipations: () => ({}),
303-
trackABTests: () => {},
304-
});
305-
};
306-
307295
export const WebRecipeStandardLabsLight: Story = {
308-
beforeEach() {
309-
mockBetaFeastContextualNudgeABVariant1();
310-
},
311296
args: {
312297
article: {
313298
...recipeStandardLifestyleWebFixture,
@@ -318,9 +303,6 @@ export const WebRecipeStandardLabsLight: Story = {
318303
};
319304

320305
export const AppsRecipeStandardLifestyleLight = {
321-
beforeEach() {
322-
mockBetaFeastContextualNudgeABVariant1();
323-
},
324306
args: {
325307
article: enhanceArticleType(RecipeStandardLifestyleFixture, 'Apps'),
326308
colourScheme: 'light',
@@ -329,9 +311,6 @@ export const AppsRecipeStandardLifestyleLight = {
329311
} satisfies Story;
330312

331313
export const AppsRecipeStandardLifestyleDark: Story = {
332-
beforeEach() {
333-
mockBetaFeastContextualNudgeABVariant1();
334-
},
335314
args: {
336315
article: AppsRecipeStandardLifestyleLight.args.article,
337316
colourScheme: 'dark',

0 commit comments

Comments
 (0)