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 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"
|
||||||
|
|||||||
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