diff --git a/src/components/AdImage.tsx b/src/components/AdImage.tsx index ac79a96..1e7f758 100644 --- a/src/components/AdImage.tsx +++ b/src/components/AdImage.tsx @@ -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//, 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(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//). + // 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 (
@@ -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); } diff --git a/src/lib/ads.ts b/src/lib/ads.ts index 972a76d..43f6900 100644 --- a/src/lib/ads.ts +++ b/src/lib/ads.ts @@ -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// 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 { + 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 { + // 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 { .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 { 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; } }