ads: adopt canonical RMP BannerTracking (AdSanity-style gross + viewport dedup)

This commit is contained in:
RMP Ops
2026-06-18 03:26:26 +00:00
parent 2623fbe4ac
commit 8719b62808
2 changed files with 170 additions and 0 deletions

View File

@@ -36,6 +36,7 @@ import GoogleAnalytics from '@/components/GoogleAnalytics';
import CookieConsent from '@/components/CookieConsent'; import CookieConsent from '@/components/CookieConsent';
import NewsletterModal from '@/components/NewsletterModal'; import NewsletterModal from '@/components/NewsletterModal';
import BannerTracking from '@/components/BannerTracking';
export const viewport: Viewport = { export const viewport: Viewport = {
width: 'device-width', width: 'device-width',
initialScale: 1 initialScale: 1
@@ -150,6 +151,7 @@ export default function RootLayout({
}} /> }} />
</head> </head>
<body className="bg-brand-bg text-brand-text"> <body className="bg-brand-bg text-brand-text">
<BannerTracking property="avbeat" />
{/* Skip to main content — WCAG 2.4.1 */} {/* Skip to main content — WCAG 2.4.1 */}
<a <a
href="#main-content" href="#main-content"

View File

@@ -0,0 +1,168 @@
"use client";
import { useEffect } from "react";
/**
* Canonical RMP banner tracking + dedup component.
*
* Synced from advertising-rmp/src/lib/canonical/BannerTracking.tsx.
* Do NOT edit per-property copies — update the canonical file in the
* advertising-rmp repo and re-sync. The RMP banner-tracking standard
* (docs: infra-docs/rmp-banner-tracking-standard.md) requires this
* exact behavior on every property — gross-impression-on-mount
* (AdSanity-style), plus viewport dedup of duplicate creatives.
*/
interface Props {
property: string;
}
const ENDPOINT = "https://advertising.relevantmediaproperties.com/api/track/impression";
const SELECTOR =
'[data-campaign-id], [data-ad-slug], [data-ad-zone], [data-banner], .ad-unit, [class*="AdImage"], [class*="AdSlot"], [class*="AdBanner"], [class*="BannerAd"]';
export default function BannerTracking({ property }: Props) {
useEffect(() => {
if (typeof window === "undefined") return;
let sid = sessionStorage.getItem("rmp.session");
if (!sid) {
sid =
typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: Math.random().toString(36).slice(2) + Date.now().toString(36);
sessionStorage.setItem("rmp.session", sid);
}
const fired = new WeakSet<Element>();
const visibleByKey = new Map<string, Element>();
const hiddenByUs = new WeakSet<Element>();
function isNested(el: Element): boolean {
return !!el.parentElement?.closest(SELECTOR);
}
function fireBeacon(el: Element): void {
if (fired.has(el)) return;
if (isNested(el)) return;
fired.add(el);
const campaign_id = el.getAttribute("data-campaign-id") || undefined;
const slug = el.getAttribute("data-ad-slug") || undefined;
if (!campaign_id && !slug) return;
const payload = JSON.stringify({
campaign_id,
slug,
property,
page_path: location.pathname,
page_url: location.href,
session_id: sid,
viewport: false,
});
try {
if (typeof navigator.sendBeacon === "function") {
navigator.sendBeacon(
ENDPOINT,
new Blob([payload], { type: "application/json" })
);
} else {
void fetch(ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: payload,
keepalive: true,
}).catch(() => {});
}
} catch {
// never throw to the page
}
}
function keyOf(el: Element): string | null {
const cid = el.getAttribute("data-campaign-id");
if (cid) return `c:${cid}`;
const anchor =
el.matches("a[href]")
? el
: el.querySelector("a[href]") ?? el.closest("a[href]");
const href = anchor?.getAttribute("href");
if (href && !href.startsWith("#")) return `h:${href}`;
const img = el.querySelector("img[src]") as HTMLImageElement | null;
const src = img?.getAttribute("src");
if (src) return `i:${src}`;
return null;
}
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) {
const el = e.target;
const key = keyOf(el);
if (!key) continue;
if (e.isIntersecting) {
const cur = visibleByKey.get(key);
if (cur && cur !== el) {
(el as HTMLElement).style.setProperty("display", "none", "important");
el.setAttribute("data-deduped", "auto");
hiddenByUs.add(el);
} else {
visibleByKey.set(key, el);
}
} else {
if (hiddenByUs.has(el)) continue;
if (visibleByKey.get(key) === el) {
visibleByKey.delete(key);
const candidates = Array.from(
document.querySelectorAll(SELECTOR)
).filter(
(c) =>
!isNested(c) && keyOf(c) === key && hiddenByUs.has(c)
);
if (candidates.length > 0) {
const c = candidates[0] as HTMLElement;
c.style.removeProperty("display");
c.removeAttribute("data-deduped");
hiddenByUs.delete(c);
}
}
}
}
},
{ threshold: 0.3 }
);
function register(el: Element): void {
if (isNested(el)) return;
fireBeacon(el);
io.observe(el);
}
document.querySelectorAll(SELECTOR).forEach(register);
const mo = new MutationObserver((muts) => {
for (const m of muts) {
m.addedNodes.forEach((n) => {
if (n.nodeType !== 1) return;
const el = n as Element;
if (el.matches?.(SELECTOR)) register(el);
el.querySelectorAll?.(SELECTOR).forEach(register);
});
}
});
mo.observe(document.body, { childList: true, subtree: true });
return () => {
io.disconnect();
mo.disconnect();
};
}, [property]);
return null;
}