A cross-sell banner in the coverage gaps
A deliberately small, well-placed injection: a Ding repairs cross-sell banner appended to the coverage section of a HomeServe cover PDP, on a Gatsby front end, instrumented in GA4. It moved awareness and sales together, and the write-up is honest about the two bugs I would fix before it earned a permanent slot.
I build and QA tests like this for brands and CRO agencies. Start a test brief →
Result
Winning variant, +9% checkout views. Recommended: research Ding awareness before a full roll-out.
The challenge
The coverage section on a cover PDP does an honest job. It lists what the policy covers and, right beside it, what it does not: a boiler breakdown that leaves you without heat, guttering, shared drains. Then it leaves the reader there. Those are precisely the people who might need an on-demand repair rather than a policy, and nothing on the page pointed them anywhere.
The task is deliberately simple. There is no live data to read and no table to build. Insert one fixed promo banner for Ding, HomeServe's repairs product, at the end of the insurance section, keep it from stacking on a re-run, and measure the two things that matter: did people see it, and did they click through.
The hypothesis
The architecture
The module layout matches the wider experiment framework, with a lighter payload: a trigger decides when to run, an orchestrator sets up tracking and injects, and the banner plus its mascot icon live as small pieces. It runs as the variation arm of a Webtrends Optimize test, with the Optimize id read back from the dataLayer.
The front end is Gatsby, so activation cannot just wait for the DOM to exist. The trigger polls for the Gatsby app root and the product section together, then holds for two seconds so React has rendered.
import activate from './lib/experiment';
import { pollerLite } from '../../../../lib/uc-lib';
// Tie activation to the Gatsby app being ready, not just the DOM existing.
pollerLite(
['body', '#___gatsby #main', '#insurance-details > .container'],
() => setTimeout(activate, 2000)
);
One component, one mascot, one CTA
The banner is a plain function returning a fixed string: the Ding character, a short line, and a "Find out more" button pointing at the repairs page. No inputs beyond the experiment id, so the build is short and the risk surface small.
import { characterIcon } from '../assets/icons';
const bannerWrapper = (id) => `
<div class="${id}__bannerWrapper">
<div class="${id}__bannerContainer">
<div class="${id}__icon">${characterIcon}</div>
<div class="${id}__info">
<div class="${id}__text">Need a job doing today? Meet <span>Ding.</span></div>
<a href="/repairs/" class="${id}__repair-button" aria-label="Find out more">Find out more</a>
</div>
</div>
</div>`.trim();
export default bannerWrapper;
Injection, and the guard that does not guard
The orchestrator finds #insurance-details and appends the banner. There is an idempotency guard, and it is the most interesting line in the file, because it does not do what it looks like it does. It checks for a .container child of the wrapper, but the banner markup renders a .bannerContainer. The condition can never match, so the guard cannot actually stop a second injection. It holds up today only because activate() runs once. The fix is a one-word change: point the guard at the class the banner really renders.
Calling that out is the point. On a 26-day test that ran once per page it never bit, but shipping it silently is how a duplicate banner turns up six months later when someone else re-runs the activation.
const init = () => {
const targetPoint = document.querySelector('#insurance-details');
// BUG: guard checks for `.container`, but the banner renders `.bannerContainer`.
// The condition is always true, so this never actually blocks a re-injection.
if (!targetPoint.querySelector(`.${ID}__bannerWrapper > .container`)) {
targetPoint.insertAdjacentHTML('beforeend', bannerWrapper(ID));
}
};
One view event, three click events
Tracking uses the shared events framework: send to GA4, fall back to a private dataLayer pusher when gtag is missing, de-duplicate by event id. A single delegated click listener handles every interaction the test cares about, the Ding CTA, the Apply Now buttons, and the upsell link in the "what isn't covered" column. Whichever arm is live, one IntersectionObserver fires a single "Conditions Met" the first time its anchor is 20% visible, gated by a body class so control and variant are measured identically. Control never mutates the DOM.
document.body.addEventListener('click', (event) => {
const { target } = event;
if (target.closest(`.${ID}__repair-button`)) {
fireEvent('User clicks the find out more CTA in the ding banner');
} else if (target.closest('#get-started a.btn') /* + the sitewide Apply buttons */) {
fireEvent('User clicks Apply now CTA');
} else if (
target.closest('#insurance-details') &&
target.closest('.measure-me-parent') &&
target.closest('.col-md-6 + .col-md-6')
) {
fireEvent("User clicks the upsell in the What isn't covered section");
}
});
What I would harden next
The banner shipped and the test read cleanly, but a handful of issues are worth fixing before code like this earns a permanent slot, and two of them are genuine bugs.
- The guard does not guard. The re-injection check looks for
.bannerWrapper > .container, but the markup renders.bannerContainer. Point it at the real class so a second activation can never stack two banners. - A trailing space is fragmenting data. The dataLayer fallback tags events with a trailing space in the experiment id (
`${ID}-${VARIATION} `) that the primarygtagpath does not have. Any events routed through the fallback land under a slightly different id in GA4. Trim it. - Strip the debugging. Two console logs and an unused dataLayer lookup are left in
init(). The Optimize check is harmless but dead. Remove them, or use the lookup. - Guard the target and loosen the coupling.
#insurance-detailsis queried without a null check, and the Apply Now tracking leans on class names from other experiments. Both are quiet ways for this to break later.
The result, with a caveat in writing
The banner moved awareness and sales together: progression to Ding rose 42.9%, claims submitted 36%, conversion rate 12.3% at 99.7% significance, and checkout views 9%. It also raised a question the team flagged, whether users understood that Ding is a separate product from the cover they were viewing. That is why the recommendation was to research before rolling out, rather than ship straight away. A win on the numbers should still be honest about what it has not proven.
- Placement: banner appended to the coverage section on the cover PDP
- Devices: all devices
- Live: 10 April to 6 May 2025 (26 days)
- Sessions: 10,058 control, 9,220 variant