ads: adopt canonical RMP BannerTracking (AdSanity-style gross + viewport dedup)
This commit is contained in:
@@ -36,6 +36,7 @@ import GoogleAnalytics from '@/components/GoogleAnalytics';
|
||||
import CookieConsent from '@/components/CookieConsent';
|
||||
import NewsletterModal from '@/components/NewsletterModal';
|
||||
|
||||
import BannerTracking from '@/components/BannerTracking';
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1
|
||||
@@ -150,6 +151,7 @@ export default function RootLayout({
|
||||
}} />
|
||||
</head>
|
||||
<body className="bg-brand-bg text-brand-text">
|
||||
<BannerTracking property="avbeat" />
|
||||
{/* Skip to main content — WCAG 2.4.1 */}
|
||||
<a
|
||||
href="#main-content"
|
||||
|
||||
168
src/components/BannerTracking.tsx
Normal file
168
src/components/BannerTracking.tsx
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user