ads: switch to centralized RMP banner source; impression beacon → advertising.rmp; rearm viewport observer (12s debounce) for re-entry counting

This commit is contained in:
Ryan Salazar
2026-05-22 05:51:54 +00:00
parent e5b09fe6d7
commit 04efa3933a
2 changed files with 108 additions and 39 deletions

View File

@@ -5,6 +5,7 @@ import AppImage from "@/components/ui/AppImage";
interface Ad {
slug?: string;
campaign_id?: string;
label: string;
size: string;
src?: string;
@@ -12,17 +13,27 @@ interface Ad {
click_url?: string;
}
// Banner renderer with impression + click tracking. Impressions are logged
// twice per visible render via navigator.sendBeacon (no fetch promise
// awaited, no impact on paint):
// 1. on mount — guarantees a count even if the user never sees the banner
// (gross-count policy, intentional)
// 2. on viewport intersection (≥500ms, threshold 0.5) — viewport=true,
// gives clients who care a "viewable" subset
// Banner renderer with impression + click tracking.
//
// Clicks always route through /r/{slug} which logs server-side then 302s
// to the click_url stored in bb.banner_creatives. We do NOT also log on
// the link's onClick — the /r/ route is the single source of truth.
// Impressions:
// - On mount: send 1 beacon (gross count).
// - On viewport entry (intersection ≥ 0.5): send another beacon — and STAY
// ARMED so re-entries count too (debounced to ≥ 12s between fires so
// scroll thrash doesn't multi-count).
//
// All impressions are POSTed to the centralized advertising endpoint
// at advertising.relevantmediaproperties.com — adv.campaigns.id is the
// canonical identifier on the wire (the slug is human-readable but the
// UUID is what RLS + analytics key off).
//
// Clicks: the Ad.click_url already points to the centralized redirect
// at relevantmediaproperties.com/<property>/<slug>, which logs the click
// to adv.banner_clicks then 302s to the advertiser URL. No client-side
// click logging is needed.
const RMP_TRACK_URL =
(process.env.NEXT_PUBLIC_RMP_ADS_API || "https://advertising.relevantmediaproperties.com")
+ "/api/track/impression";
export default function AdImage({
ad,
@@ -35,26 +46,30 @@ export default function AdImage({
}) {
const containerRef = useRef<HTMLDivElement>(null);
const beaconMount = useRef(false);
const beaconViewport = useRef(false);
const lastViewportFire = useRef(0);
useEffect(() => {
if (!ad.src || !ad.slug) return;
if (!ad.src) return;
const id = ad.campaign_id || ad.slug;
if (!id) return;
if (!beaconMount.current) {
beaconMount.current = true;
sendImpression(ad.slug, page, false);
sendImpression(id, page, false);
}
const el = containerRef.current;
if (!el || beaconViewport.current) return;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.isIntersecting && !beaconViewport.current) {
beaconViewport.current = true;
sendImpression(ad.slug!, page, true);
observer.disconnect();
return;
if (e.isIntersecting) {
const now = Date.now();
if (now - lastViewportFire.current >= 12000) {
lastViewportFire.current = now;
sendImpression(id, page, true);
}
}
}
},
@@ -62,12 +77,16 @@ export default function AdImage({
);
observer.observe(el);
return () => observer.disconnect();
}, [ad.src, ad.slug, page]);
}, [ad.src, ad.slug, ad.campaign_id, page]);
if (!ad.src) return null;
const [w, h] = ad.size.split("x").map((n) => parseInt(n, 10));
const clickHref = ad.slug ? `/r/${ad.slug}` : ad.click_url || "";
// ad.click_url comes from lib/ads.ts already pointing at the centralized
// redirect (https://www.relevantmediaproperties.com/<property>/<slug>).
// Legacy fallback rows still have a direct click_url; either way we send
// the user there.
const clickHref = ad.click_url || (ad.slug ? `/r/${ad.slug}` : "");
return (
<div ref={containerRef} className="ad-container">
@@ -82,14 +101,12 @@ export default function AdImage({
);
}
function sendImpression(slug: string, page: string | undefined, viewport: boolean) {
function sendImpression(id: string, page: string | undefined, viewport: boolean) {
if (typeof window === "undefined") return;
// We need the campaign UUID, not the slug, on the wire — but the
// server-side /api/track/impression endpoint accepts slug-or-uuid and
// resolves it. To keep payload tiny and avoid leaking IDs, we send
// slug + the viewport flag.
// The RMP track endpoint accepts campaign_id (UUID). If we only have a
// slug, the centralized endpoint resolves it to a UUID server-side.
const payload = JSON.stringify({
slug,
campaign_id: id,
page_path: page || window.location.pathname,
page_url: window.location.href,
viewport,
@@ -98,17 +115,18 @@ function sendImpression(slug: string, page: string | undefined, viewport: boolea
const ok =
typeof navigator.sendBeacon === "function" &&
navigator.sendBeacon(
"/api/track/impression",
RMP_TRACK_URL,
new Blob([payload], { type: "application/json" }),
);
if (ok) return;
} catch {
// fall through to fetch
}
fetch("/api/track/impression", {
fetch(RMP_TRACK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: payload,
keepalive: true,
mode: "no-cors",
}).catch(() => undefined);
}

View File

@@ -13,6 +13,7 @@ import { createClient } from "@supabase/supabase-js";
export interface Ad {
slug?: string;
campaign_id?: string;
label: string;
size: "300x250" | "300x600" | "728x90";
src: string;
@@ -70,7 +71,65 @@ interface DbRow {
size: "300x250" | "300x600" | "728x90";
}
// Centralized banner source: advertising.relevantmediaproperties.com
// serves /api/banners/<property>/<placement> with CORS + 5min cache.
// We fetch all three sizes once and merge.
const RMP_API = process.env.NEXT_PUBLIC_RMP_ADS_API
|| "https://advertising.relevantmediaproperties.com";
const PROPERTY = process.env.NEXT_PUBLIC_RMP_PROPERTY || "broadcastbeat";
interface RmpBanner {
campaign_id: string;
name: string;
image_url: string;
image_webp_url?: string | null;
click_url: string;
width: number;
height: number;
placement: "300x250" | "300x600" | "728x90";
property: string;
slug?: string;
}
async function fetchPlacement(size: Ad["size"]): Promise<Ad[]> {
try {
const res = await fetch(`${RMP_API}/api/banners/${PROPERTY}/${size}`, {
next: { revalidate: 300 },
});
if (!res.ok) return [];
const data = await res.json() as { banners?: RmpBanner[] };
return (data.banners || []).map((b): Ad => ({
// Prefer the human-readable slug for the centralized click URL; fall back
// to the campaign UUID if the slug column ever ends up empty.
slug: b.slug || b.campaign_id,
campaign_id: b.campaign_id,
label: b.name,
size: b.placement,
src: b.image_url,
alt: b.name,
click_url: `https://www.relevantmediaproperties.com/${PROPERTY}/${b.slug || b.campaign_id}`,
}));
} catch {
return [];
}
}
async function loadFromDb(): Promise<Ad[]> {
// 1) Try centralized RMP source first (preferred path).
try {
const [a, b, c] = await Promise.all([
fetchPlacement("300x250"),
fetchPlacement("300x600"),
fetchPlacement("728x90"),
]);
const all = [...a, ...b, ...c];
if (all.length > 0) return all;
} catch {
/* fall through */
}
// 2) Legacy fallback: query BB's own bb.banner_creatives (kept until the
// centralized RMP source is fully verified in prod).
if (!SUPABASE_URL || !SUPABASE_ANON) return FALLBACK;
try {
const sb = createClient(SUPABASE_URL, SUPABASE_ANON, {
@@ -84,12 +143,7 @@ async function loadFromDb(): Promise<Ad[]> {
.eq("status", "active")
.or(`start_date.is.null,start_date.lte.${nowIso}`)
.or(`end_date.is.null,end_date.gte.${nowIso}`);
if (error || !data || data.length === 0) {
if (typeof console !== "undefined") {
console.warn("[ads] db query empty/error, using fallback:", error?.message || "no rows");
}
return FALLBACK;
}
if (error || !data || data.length === 0) return FALLBACK;
return (data as DbRow[]).map((r) => ({
slug: r.slug,
label: r.name,
@@ -98,10 +152,7 @@ async function loadFromDb(): Promise<Ad[]> {
alt: r.name,
click_url: r.click_url || undefined,
}));
} catch (err: any) {
if (typeof console !== "undefined") {
console.warn("[ads] db query threw, using fallback:", err?.message);
}
} catch {
return FALLBACK;
}
}