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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user