Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
6a1aab6
Add Braze banner integration to Feast contextual nudge component
andresilva-guardian Jun 17, 2026
79f34e5
Add nudgeIndex and idApiUrl to FeastContextualNudge story args
andresilva-guardian Jun 17, 2026
eda9dd0
Merge branch 'main' into afs/feast-contextual-nudge-braze-banners
andresilva-guardian Jun 24, 2026
2bcaf2f
Add GetContext message type and context parameter to Braze Banners Sy…
andresilva-guardian Jun 24, 2026
1a2962c
Add context prop to FeastContextualNudge component
andresilva-guardian Jun 24, 2026
0c4ea5e
Remove unused text color variables from FeastContextualNudge component
andresilva-guardian Jun 24, 2026
d82581c
Refactor FeastContextualNudge component to replace subtext variables …
andresilva-guardian Jun 24, 2026
d18edc4
Wrap BrazeBannersSystemDisplay in a div with aria-description for acc…
andresilva-guardian Jun 24, 2026
e6f0950
Add placement management for Braze Banners System and update refresh …
andresilva-guardian Jun 25, 2026
4802c0e
Enhance logging for Braze banners updates to provide clearer information
andresilva-guardian Jun 26, 2026
bcc02f4
Add stale placement handling for Braze banners to improve fallback logic
andresilva-guardian Jun 26, 2026
25b88c1
Refactor Braze Banners System to implement per-placement stale suppre…
andresilva-guardian Jun 26, 2026
212591f
Refactor getAdjustToken function for cleaner token retrieval in Feast…
andresilva-guardian Jun 30, 2026
5f83369
Merge branch 'main' into afs/feast-contextual-nudge-braze-banners
andresilva-guardian Jul 9, 2026
de918ef
Add nudgeMinHeightStyles to prevent content shift during Braze banner…
andresilva-guardian Jul 13, 2026
e279321
Build Feast deep-link URL using the correct Adjust token in FeastCont…
andresilva-guardian Jul 17, 2026
b28e7f0
Temporarily force nudge rendering for testing purposes in FeastContex…
andresilva-guardian Jul 17, 2026
857a4b1
Merge branch 'afs/feast-contextual-nudge-braze-banners' of https://gi…
andresilva-guardian Jul 17, 2026
fc1e23c
Revert "Build Feast deep-link URL using the correct Adjust token in F…
andresilva-guardian Jul 17, 2026
9cc00ac
Implement "Saved from web" feature for Feast recipes with API integra…
andresilva-guardian Jul 17, 2026
1fe6801
Add support for batch fetching "Saved from web" recipe states in Feas…
andresilva-guardian Jul 21, 2026
e0570e4
Refactor Feast saved recipes caching to use user ID as key and optimi…
andresilva-guardian Jul 21, 2026
98d2e6a
Update @guardian/identity-auth-frontend version to 0.0.0-canary-20260…
andresilva-guardian Jul 23, 2026
7708a5f
Plan: resolve merge conflicts with main
Copilot Jul 28, 2026
9e23683
Resolve merge conflicts with main
Copilot Jul 28, 2026
568fd9f
Remove A/B testing logic from FeastContextualNudge component
andresilva-guardian Jul 28, 2026
6dab519
Add timeout for recipe saving status and improve layout consistency
andresilva-guardian Jul 28, 2026
751e35b
Refactor Feast recipe saving endpoints to use Frontend server proxy a…
andresilva-guardian Jul 28, 2026
feaa30b
Add tests for Feast API's "Saved from web" endpoints and refactor API…
andresilva-guardian Jul 28, 2026
b812304
Refactor Feast API calls to use relative same-origin proxy and update…
andresilva-guardian Jul 28, 2026
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
2 changes: 1 addition & 1 deletion dotcom-rendering/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"@guardian/core-web-vitals": "7.0.0",
"@guardian/eslint-config": "catalog:",
"@guardian/identity-auth": "6.0.1",
"@guardian/identity-auth-frontend": "8.1.0",
"@guardian/identity-auth-frontend": "0.0.0-canary-20260722105844",
"@guardian/libs": "32.0.0",
"@guardian/ophan-tracker-js": "4.0.2",
"@guardian/react-crossword": "19.0.1",
Expand Down
105 changes: 98 additions & 7 deletions dotcom-rendering/src/components/FeastContextualNudge.island.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
BrazeBannersSystemPlacementId,
isPlacementStale,
} from '../lib/braze/BrazeBannersSystem';
import { getFeastSavedFromTheWebRecipes } from '../lib/feast/savedFromWeb';
import { useAuthStatus } from '../lib/useAuthStatus';
import { useBraze } from '../lib/useBraze';
import type { StageType } from '../types/config';
import type { RecipeBlockElement } from '../types/content';
Expand Down Expand Up @@ -116,6 +118,27 @@ const nudgeMinHeightStyles = css`
}
`;

/**
* Extra vertical margin applied around the reserved-height box, matching
* `showcaseCardStyles`' own margin so the placeholder shown while the
* "Saved from web" status is loading (see `isRecipeSaved` below) takes up
* exactly the same space as whichever of the Braze banner or native card
* replaces it, keeping the CLS mitigation consistent across all three
* states.
*/
const nudgeSpacingStyles = css`
margin: ${space[2]}px 0;
`;

/**
* Upper bound on how long to wait for `getFeastSavedFromTheWebRecipes` (see
* below) before giving up and treating the recipe as not-saved. Prevents an
* unusually slow/hanging request from blocking the nudge from rendering at
* all. Mirrors the timeout pattern used for `fetchEmail` in
* `BrazeBannersSystem.tsx`.
*/
const SAVED_FROM_WEB_TIMEOUT_MS = 2_000;

// ── Card styles ───────────────────────────────────────────────────────────────

const showcaseCardStyles = css`
Expand Down Expand Up @@ -163,6 +186,14 @@ type FeastContextualNudgeProps = {
isDev: boolean;
nudgeIndex: number;
idApiUrl: string | undefined;
/**
* Every recipe id that will get a nudge on this page (at most 5). Shared
* across all FeastContextualNudge instances so that whichever one
* hydrates first fetches the reader's "Saved from web" state for the
* whole batch in a single request, rather than each nudge querying just
* its own recipe id separately.
*/
allNudgeRecipeIds: string[];
};

/**
Expand All @@ -185,9 +216,9 @@ export const FeastContextualNudge = ({
isDev,
nudgeIndex,
idApiUrl,
allNudgeRecipeIds,
}: FeastContextualNudgeProps) => {
const { darkModeAvailable, renderingTarget } = useConfig();

const { braze } = useBraze(idApiUrl ?? '', renderingTarget);

const [isStorybook, setIsStorybook] = useState(false);
Expand Down Expand Up @@ -229,6 +260,70 @@ export const FeastContextualNudge = ({
}
}, [feastId, title, pageId, isDev]);

// Whether this recipe is already in the reader's "Saved from web" list.
// `getFeastSavedFromTheWebRecipes` caches by user id + recipe ids, so no
// matter how many FeastContextualNudge islands on this page call it as
// they hydrate (each is deferred `until: 'visible'`), only one network
// request for the batch of recipe ids on this page is ever made.
//
// `undefined` specifically means "not yet known" (auth status still
// pending, or the saved-from-web request is still in flight) as opposed
// to `false`, which means "known to not be saved". The Braze banner
// iframe reads `isRecipeSaved` from `context` only once, when it asks for
// context on load (see `GetContext` in `BrazeBannersSystem.tsx`) — so the
// banner must not be rendered until the real value is known, otherwise it
// would be permanently stuck showing the wrong saved state. See the
// render gate below, which holds off rendering anything (Braze banner
// *or* native fallback) until this resolves.
const authStatus = useAuthStatus();
const [isRecipeSaved, setIsRecipeSaved] = useState<boolean | undefined>(
undefined,
);
useEffect(() => {
if (authStatus.kind === 'Pending') return;
if (authStatus.kind !== 'SignedIn') {
setIsRecipeSaved(false);
return;
}

let cancelled = false;
const timeout = new Promise<Set<string>>((resolve) => {
setTimeout(() => resolve(new Set()), SAVED_FROM_WEB_TIMEOUT_MS);
});
void Promise.race([
getFeastSavedFromTheWebRecipes(
authStatus.idToken.claims.sub,
authStatus.accessToken.accessToken,
allNudgeRecipeIds,
),
timeout,
]).then((savedRecipeIds) => {
if (!cancelled) setIsRecipeSaved(savedRecipeIds.has(feastId));
});
return () => {
cancelled = true;
};
}, [authStatus, feastId, allNudgeRecipeIds]);

// Only the native fallback renders `isRecipeSaved`-free, so it's safe to
// show straight away when there's no Braze placement to wait on. When a
// Braze placement is possible, hold off on rendering anything until the
// saved-from-web status is known, so the native card never flashes before
// the Braze banner is ready to show with the correct context, and so the
// banner is never shown with a stale/incorrect `isRecipeSaved`. The
// reserved height/margin (`nudgeMinHeightStyles` + `nudgeSpacingStyles`)
// matches the Braze/native cards below, so this causes no layout shift
// once real content replaces it.
if (idApiUrl !== undefined && isRecipeSaved === undefined) {
return (
<div
data-component="feast-contextual-nudge"
aria-hidden="true"
css={[nudgeMinHeightStyles, nudgeSpacingStyles]}
/>
);
}

// 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) {
Expand All @@ -254,12 +349,7 @@ export const FeastContextualNudge = ({
<div
aria-description={`Open the recipe ${title} in the Feast app`}
data-component="feast-contextual-nudge"
css={[
nudgeMinHeightStyles,
css`
margin: ${space[2]}px 0;
`,
]}
css={[nudgeMinHeightStyles, nudgeSpacingStyles]}
>
<BrazeBannersSystemDisplay
meta={{
Expand All @@ -277,6 +367,7 @@ export const FeastContextualNudge = ({
nudgeIndex,
darkMode: darkModeAvailable,
adjustToken: getAdjustToken(stage),
isRecipeSaved,
}}
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const meta = {
isDev: true,
nudgeIndex: 1,
idApiUrl: undefined,
allNudgeRecipeIds: [mockRecipe.id],
},
parameters: {
chromatic: { viewports: [375, 740, 980] },
Expand Down
21 changes: 20 additions & 1 deletion dotcom-rendering/src/lib/ArticleRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,11 +168,29 @@ export const ArticleRenderer = ({
const MAX_NUDGES = 5;
const interval = Math.ceil(sections.length / MAX_NUDGES);

for (const section of sections) {
const sectionsWithNudgeIndex = sections.map((section) => {
const position = section.index + 1; // 1-based
const nudgeIndex =
position % interval === 0 ? position / interval : null;
return { section, nudgeIndex };
});

/**
* Every recipe id that will get a nudge on this page, known upfront
* (at most MAX_NUDGES). Passed to every FeastContextualNudge
* instance so that whichever one hydrates first can fetch the
* reader's "Saved from web" state for the whole batch in a single
* request (see `getFeastSavedFromTheWebRecipes`), rather than each
* nudge querying just its own recipe id separately.
*/
const allNudgeRecipeIds = sectionsWithNudgeIndex
.filter(
({ section, nudgeIndex }) =>
section.recipe !== undefined && nudgeIndex !== null,
)
.map(({ section }) => section.recipe!.id);

for (const { section, nudgeIndex } of sectionsWithNudgeIndex) {
result.push(
<Fragment key={`recipe-section-${section.index}`}>
{section.subheadingEl}
Expand All @@ -189,6 +207,7 @@ export const ArticleRenderer = ({
recipeArticleTitle={section.recipeArticleTitle}
nudgeIndex={nudgeIndex}
idApiUrl={idApiUrl}
allNudgeRecipeIds={allNudgeRecipeIds}
/>
</Island>
)}
Expand Down
70 changes: 70 additions & 0 deletions dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ 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 { addFeastRecipeToSavedFromWebList } from '../feast/savedFromWeb';
import { getAuthState, getOptionsHeaders } from '../identity';
import type { CandidateConfig, CanShowResult } from '../messagePicker';
import { useAuthStatus } from '../useAuthStatus';
Expand Down Expand Up @@ -504,6 +505,7 @@ enum BrazeBannersSystemMessageType {
NavigateToUrl = 'BRAZE_BANNERS_SYSTEM:NAVIGATE_TO_URL',
DismissBanner = 'BRAZE_BANNERS_SYSTEM:DISMISS_BANNER',
GetContext = 'BRAZE_BANNERS_SYSTEM:GET_CONTEXT',
SaveFeastRecipeById = 'BRAZE_BANNERS_SYSTEM:SAVE_FEAST_RECIPE_BY_ID',
}

/**
Expand Down Expand Up @@ -675,6 +677,41 @@ export const BrazeBannersSystemDisplay = ({
threshold: 0,
});

/**
* Save a Feast recipe by its Id on the "Saved from the Web" recipes list on the App.
* Proxied through Frontend's own server (`/api/feast-saved-recipes/:recipeId`),
* which forwards the reader's bearer token to the Feast API. Idempotent:
* saving the same recipe id twice has no extra effect.
* @param feastRecipeId The ID of the Feast recipe to save
* @returns A promise that resolves to true if the add process was successful, or false if it failed or the user is not signed in
*/
const saveFeastRecipeById = useCallback(
async (feastRecipeId: string): Promise<boolean> => {
if (authStatus.kind === 'SignedIn') {
const success = await addFeastRecipeToSavedFromWebList(
authStatus.accessToken.accessToken,
feastRecipeId,
);

if (success) {
brazeBannersSystemLogger.info(
'Successfully saved recipe to the "Saved from web" list:',
feastRecipeId,
);
} else {
brazeBannersSystemLogger.warn(
'Failed to save recipe to the "Saved from web" list:',
feastRecipeId,
);
}

return success;
}
return false;
},
[authStatus],
);

/**
* Subscribes the user to a newsletter via the Identity API.
* Only attempts to subscribe if the user is signed in and a newsletter ID is provided.
Expand Down Expand Up @@ -968,6 +1005,10 @@ export const BrazeBannersSystemDisplay = ({
| {
type: BrazeBannersSystemMessageType.GetContext;
}
| {
type: BrazeBannersSystemMessageType.SaveFeastRecipeById;
feastRecipeId?: string;
}
>,
) => {
if (
Expand Down Expand Up @@ -1118,6 +1159,34 @@ export const BrazeBannersSystemDisplay = ({
},
);
break;
case BrazeBannersSystemMessageType.SaveFeastRecipeById: {
meta.braze.logBannerClick(
meta.banner,
'save_feast_recipe_button',
);
const { feastRecipeId } = event.data;
if (feastRecipeId !== undefined) {
void saveFeastRecipeById(feastRecipeId).then(
(success) => {
postMessageToBrazeBanner(
BrazeBannersSystemMessageType.SaveFeastRecipeById,
{
success,
},
);
meta.braze.logCustomEvent(
'braze_banner_save_feast_recipe',
{
placementId: meta.banner.placementId,
feastRecipeId,
success,
},
);
},
);
}
break;
}
}
};

Expand All @@ -1136,6 +1205,7 @@ export const BrazeBannersSystemDisplay = ({
logNavigateToUrlClick,
postMessageToBrazeBanner,
context,
saveFeastRecipeById,
]);

// Log Impressions when the banner is seen, using the hasBeenSeen value from the useIsInView hook
Expand Down
Loading
Loading