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 {
|
interface Ad {
|
||||||
slug?: string;
|
slug?: string;
|
||||||
|
campaign_id?: string;
|
||||||
label: string;
|
label: string;
|
||||||
size: string;
|
size: string;
|
||||||
src?: string;
|
src?: string;
|
||||||
@@ -12,17 +13,27 @@ interface Ad {
|
|||||||
click_url?: string;
|
click_url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Banner renderer with impression + click tracking. Impressions are logged
|
// Banner renderer with impression + click tracking.
|
||||||
// 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
|
|
||||||
//
|
//
|
||||||
// Clicks always route through /r/{slug} which logs server-side then 302s
|
// Impressions:
|
||||||
// to the click_url stored in bb.banner_creatives. We do NOT also log on
|
// - On mount: send 1 beacon (gross count).
|
||||||
// the link's onClick — the /r/ route is the single source of truth.
|
// - 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({
|
export default function AdImage({
|
||||||
ad,
|
ad,
|
||||||
@@ -35,26 +46,30 @@ export default function AdImage({
|
|||||||
}) {
|
}) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const beaconMount = useRef(false);
|
const beaconMount = useRef(false);
|
||||||
const beaconViewport = useRef(false);
|
const lastViewportFire = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ad.src || !ad.slug) return;
|
if (!ad.src) return;
|
||||||
|
const id = ad.campaign_id || ad.slug;
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
if (!beaconMount.current) {
|
if (!beaconMount.current) {
|
||||||
beaconMount.current = true;
|
beaconMount.current = true;
|
||||||
sendImpression(ad.slug, page, false);
|
sendImpression(id, page, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
const el = containerRef.current;
|
const el = containerRef.current;
|
||||||
if (!el || beaconViewport.current) return;
|
if (!el) return;
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
if (e.isIntersecting && !beaconViewport.current) {
|
if (e.isIntersecting) {
|
||||||
beaconViewport.current = true;
|
const now = Date.now();
|
||||||
sendImpression(ad.slug!, page, true);
|
if (now - lastViewportFire.current >= 12000) {
|
||||||
observer.disconnect();
|
lastViewportFire.current = now;
|
||||||
return;
|
sendImpression(id, page, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -62,12 +77,16 @@ export default function AdImage({
|
|||||||
);
|
);
|
||||||
observer.observe(el);
|
observer.observe(el);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [ad.src, ad.slug, page]);
|
}, [ad.src, ad.slug, ad.campaign_id, page]);
|
||||||
|
|
||||||
if (!ad.src) return null;
|
if (!ad.src) return null;
|
||||||
|
|
||||||
const [w, h] = ad.size.split("x").map((n) => parseInt(n, 10));
|
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 (
|
return (
|
||||||
<div ref={containerRef} className="ad-container">
|
<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;
|
if (typeof window === "undefined") return;
|
||||||
// We need the campaign UUID, not the slug, on the wire — but the
|
// The RMP track endpoint accepts campaign_id (UUID). If we only have a
|
||||||
// server-side /api/track/impression endpoint accepts slug-or-uuid and
|
// slug, the centralized endpoint resolves it to a UUID server-side.
|
||||||
// resolves it. To keep payload tiny and avoid leaking IDs, we send
|
|
||||||
// slug + the viewport flag.
|
|
||||||
const payload = JSON.stringify({
|
const payload = JSON.stringify({
|
||||||
slug,
|
campaign_id: id,
|
||||||
page_path: page || window.location.pathname,
|
page_path: page || window.location.pathname,
|
||||||
page_url: window.location.href,
|
page_url: window.location.href,
|
||||||
viewport,
|
viewport,
|
||||||
@@ -98,17 +115,18 @@ function sendImpression(slug: string, page: string | undefined, viewport: boolea
|
|||||||
const ok =
|
const ok =
|
||||||
typeof navigator.sendBeacon === "function" &&
|
typeof navigator.sendBeacon === "function" &&
|
||||||
navigator.sendBeacon(
|
navigator.sendBeacon(
|
||||||
"/api/track/impression",
|
RMP_TRACK_URL,
|
||||||
new Blob([payload], { type: "application/json" }),
|
new Blob([payload], { type: "application/json" }),
|
||||||
);
|
);
|
||||||
if (ok) return;
|
if (ok) return;
|
||||||
} catch {
|
} catch {
|
||||||
// fall through to fetch
|
// fall through to fetch
|
||||||
}
|
}
|
||||||
fetch("/api/track/impression", {
|
fetch(RMP_TRACK_URL, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: payload,
|
body: payload,
|
||||||
keepalive: true,
|
keepalive: true,
|
||||||
|
mode: "no-cors",
|
||||||
}).catch(() => undefined);
|
}).catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { createClient } from "@supabase/supabase-js";
|
|||||||
|
|
||||||
export interface Ad {
|
export interface Ad {
|
||||||
slug?: string;
|
slug?: string;
|
||||||
|
campaign_id?: string;
|
||||||
label: string;
|
label: string;
|
||||||
size: "300x250" | "300x600" | "728x90";
|
size: "300x250" | "300x600" | "728x90";
|
||||||
src: string;
|
src: string;
|
||||||
@@ -70,7 +71,65 @@ interface DbRow {
|
|||||||
size: "300x250" | "300x600" | "728x90";
|
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[]> {
|
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;
|
if (!SUPABASE_URL || !SUPABASE_ANON) return FALLBACK;
|
||||||
try {
|
try {
|
||||||
const sb = createClient(SUPABASE_URL, SUPABASE_ANON, {
|
const sb = createClient(SUPABASE_URL, SUPABASE_ANON, {
|
||||||
@@ -84,12 +143,7 @@ async function loadFromDb(): Promise<Ad[]> {
|
|||||||
.eq("status", "active")
|
.eq("status", "active")
|
||||||
.or(`start_date.is.null,start_date.lte.${nowIso}`)
|
.or(`start_date.is.null,start_date.lte.${nowIso}`)
|
||||||
.or(`end_date.is.null,end_date.gte.${nowIso}`);
|
.or(`end_date.is.null,end_date.gte.${nowIso}`);
|
||||||
if (error || !data || data.length === 0) {
|
if (error || !data || data.length === 0) return FALLBACK;
|
||||||
if (typeof console !== "undefined") {
|
|
||||||
console.warn("[ads] db query empty/error, using fallback:", error?.message || "no rows");
|
|
||||||
}
|
|
||||||
return FALLBACK;
|
|
||||||
}
|
|
||||||
return (data as DbRow[]).map((r) => ({
|
return (data as DbRow[]).map((r) => ({
|
||||||
slug: r.slug,
|
slug: r.slug,
|
||||||
label: r.name,
|
label: r.name,
|
||||||
@@ -98,10 +152,7 @@ async function loadFromDb(): Promise<Ad[]> {
|
|||||||
alt: r.name,
|
alt: r.name,
|
||||||
click_url: r.click_url || undefined,
|
click_url: r.click_url || undefined,
|
||||||
}));
|
}));
|
||||||
} catch (err: any) {
|
} catch {
|
||||||
if (typeof console !== "undefined") {
|
|
||||||
console.warn("[ads] db query threw, using fallback:", err?.message);
|
|
||||||
}
|
|
||||||
return FALLBACK;
|
return FALLBACK;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user