feat(avbeat): tracked FALLBACK /r/ + SiteAnalyticsBeacon (PIN 4769)

This commit is contained in:
RMP Ops
2026-07-17 04:53:35 +00:00
parent 9af7af6286
commit 7bdba62c58
5 changed files with 292 additions and 51 deletions

View File

@@ -37,6 +37,7 @@ import CookieConsent from '@/components/CookieConsent';
import NewsletterModal from '@/components/NewsletterModal'; import NewsletterModal from '@/components/NewsletterModal';
import BannerTracking from '@/components/BannerTracking'; import BannerTracking from '@/components/BannerTracking';
import SiteAnalyticsBeacon from '@/components/SiteAnalyticsBeacon';
export const viewport: Viewport = { export const viewport: Viewport = {
width: 'device-width', width: 'device-width',
initialScale: 1 initialScale: 1
@@ -152,6 +153,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" /> <BannerTracking property="avbeat" />
<SiteAnalyticsBeacon propertySlug="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"

View File

@@ -13,27 +13,9 @@ interface Ad {
click_url?: string; click_url?: string;
} }
// Banner renderer with impression + click tracking.
//
// 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 = const RMP_TRACK_URL =
(process.env.NEXT_PUBLIC_RMP_ADS_API || "https://advertising.relevantmediaproperties.com") (process.env.NEXT_PUBLIC_RMP_ADS_API || "https://advertising.relevantmediaproperties.com") +
+ "/api/track/impression"; "/api/track/impression";
export default function AdImage({ export default function AdImage({
ad, ad,
@@ -46,33 +28,31 @@ export default function AdImage({
}) { }) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const beaconMount = useRef(false); const beaconMount = useRef(false);
const lastViewportFire = useRef(0);
useEffect(() => { useEffect(() => {
if (!ad.src) return; if (!ad.src) return;
const id = ad.campaign_id || ad.slug; const id = ad.campaign_id || ad.slug;
if (!id) return; if (!id) return;
// One impression per pageview per banner, fired on mount. No viewport
// gate — too many ad slots sit below-the-fold and the IO threshold
// suppressed real signal.
if (!beaconMount.current) { if (!beaconMount.current) {
beaconMount.current = true; beaconMount.current = true;
sendImpression(id, page, false); sendImpression(id, ad.slug, page, false);
} }
}, [ad.src, ad.slug, ad.campaign_id, 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));
// ad.click_url comes from lib/ads.ts already pointing at the centralized const clickHref = ad.click_url || (ad.campaign_id ? `/r/${ad.campaign_id}` : ad.slug ? `/r/${ad.slug}` : "");
// 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"
data-campaign-id={ad.campaign_id || undefined}
data-ad-slug={ad.slug || undefined}
data-banner="1"
>
{clickHref ? ( {clickHref ? (
<a href={clickHref} target="_blank" rel="sponsored noopener noreferrer"> <a href={clickHref} target="_blank" rel="sponsored noopener noreferrer">
<AppImage src={ad.src} alt={ad.alt || ad.label} width={w} height={h} priority={priority} /> <AppImage src={ad.src} alt={ad.alt || ad.label} width={w} height={h} priority={priority} />
@@ -84,12 +64,16 @@ export default function AdImage({
); );
} }
function sendImpression(id: string, page: string | undefined, viewport: boolean) { function sendImpression(
id: string,
slug: string | undefined,
page: string | undefined,
viewport: boolean,
) {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
// 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({ const payload = JSON.stringify({
campaign_id: id, campaign_id: id,
slug: slug || undefined,
page_path: page || window.location.pathname, page_path: page || window.location.pathname,
page_url: window.location.href, page_url: window.location.href,
viewport, viewport,
@@ -97,19 +81,16 @@ function sendImpression(id: string, page: string | undefined, viewport: boolean)
try { try {
const ok = const ok =
typeof navigator.sendBeacon === "function" && typeof navigator.sendBeacon === "function" &&
navigator.sendBeacon( navigator.sendBeacon(RMP_TRACK_URL, new Blob([payload], { type: "application/json" }));
RMP_TRACK_URL,
new Blob([payload], { type: "application/json" }),
);
if (ok) return; if (ok) return;
} catch { } catch {
// fall through to fetch /* fall through */
} }
fetch(RMP_TRACK_URL, { 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", mode: "cors",
}).catch(() => undefined); }).catch(() => undefined);
} }

View File

@@ -0,0 +1,147 @@
import React from "react";
import Link from "next/link";
import AppImage from "@/components/ui/AppImage";
import AnimatedLogo from "@/components/AnimatedLogo";
import {
LinkedInIcon,
InstagramIcon,
TwitterXIcon,
FacebookIcon } from
"@/components/ui/Icons";
import AdImage from "@/components/AdImage";
import { pickAds } from "@/lib/ads";
// DO NOT OVERRIDE — Footer column structure and link targets
const footerColumns = [
{
heading: "About",
links: [
{ label: "About AV Beat", href: "/about" },
{ label: "Our Mission", href: "/about#mission" },
{ label: "Editorial Team", href: "/about#team" },
{ label: "Advertise With Us", href: "/advertise" },
{ label: "Contact Us", href: "/advertise#contact" },
{ label: "Newsletter", href: "/home-page#newsletter" },
{ label: "Video Archive", href: "/video-archive" }]
},
{
heading: "Categories",
links: [
{ label: "Industry News", href: "/news" },
{ label: "Gear & Reviews", href: "/gear" },
{ label: "Show Coverage", href: "/show-coverage" },
{ label: "Production Technology", href: "/technology" },
{ label: "Live Production", href: "/news?category=live-production" },
{ label: "AI & Automation", href: "/technology?category=ai" },
{ label: "Streaming", href: "/technology?category=streaming" },
{ label: "Audio", href: "/gear?category=audio" }]
},
{
heading: "Events",
links: [
{ label: "NAB Show", href: "/show-coverage" },
{ label: "IBC", href: "/show-coverage?event=ibc" },
{ label: "Cine Gear", href: "/show-coverage?event=cine-gear" },
{ label: "SXSW", href: "/show-coverage?event=sxsw" },
{ label: "Streaming Summit", href: "/show-coverage?event=streaming-summit" },
{ label: "All Events", href: "/show-coverage" }]
},
{
heading: "Follow Us",
links: [
{ label: "LinkedIn", href: "https://linkedin.com/company/avbeat" },
{ label: "Twitter / X", href: "https://twitter.com/avbeat" },
{ label: "Facebook", href: "https://facebook.com/avbeat" },
{ label: "Instagram", href: "https://instagram.com/avbeat" },
{ label: "YouTube", href: "https://youtube.com/@avbeat" },
{ label: "RSS Feed", href: "/rss" }]
}];
export default function Footer() {
return (
<footer className="bg-[#F8FAFC] border-t border-[#DCE6F2]">
{/* Footer Ad Row — site-wide 728x90 (rotates if multiple in pool) */}
<div className="max-w-container mx-auto px-4 pt-8 pb-4">
{pickAds("728x90", 1).length > 0 && (
<div className="flex justify-center mb-8">
<AdImage ad={pickAds("728x90", 1)[0]} page="footer" />
</div>
)}
{/* Multi-column link section */}
<div className="border-t border-[#DCE6F2] pt-8 pb-6 grid grid-cols-2 md:grid-cols-4 gap-8">
{footerColumns?.map((col) =>
<div key={col?.heading}>
<h4 className="font-body font-bold text-xs text-[#1D4ED8] uppercase tracking-widest mb-4 pb-2 border-b border-[#1D4ED8]/20">
{col?.heading}
</h4>
<ul className="space-y-2">
{col?.links?.map((link) =>
<li key={link?.label}>
{link?.href?.startsWith("http") ?
<a
href={link?.href}
target="_blank"
rel="noopener noreferrer"
className="footer-link font-body text-sm text-[#475569] hover:text-[#1D4ED8] transition-colors leading-relaxed inline-block">
{link?.label}
</a> :
<Link
href={link?.href}
className="footer-link font-body text-sm text-[#475569] hover:text-[#1D4ED8] transition-colors leading-relaxed inline-block">
{link?.label}
</Link>
}
</li>
)}
</ul>
</div>
)}
</div>
{/* Footer Bottom */}
{/* DO NOT OVERRIDE — footer bottom bar layout */}
<div className="border-t border-[#DCE6F2] pt-5 pb-3 flex flex-col sm:flex-row items-center justify-between gap-4">
<Link
href="/home-page"
aria-label="AV Beat — home"
className="flex-shrink-0 opacity-90 hover:opacity-100 transition-opacity">
<AnimatedLogo size={36} variant="onLight" />
</Link>
<div className="flex items-center gap-4">
<a href="https://linkedin.com/company/avbeat" target="_blank" rel="noopener noreferrer" aria-label="AV Beat on LinkedIn"
className="text-[#475569] hover:text-[#1D4ED8] transition-colors social-icon">
<LinkedInIcon size={17} />
</a>
<a href="https://instagram.com/avbeat" target="_blank" rel="noopener noreferrer" aria-label="AV Beat on Instagram"
className="text-[#475569] hover:text-[#1D4ED8] transition-colors social-icon">
<InstagramIcon size={17} />
</a>
<a href="https://twitter.com/avbeat" target="_blank" rel="noopener noreferrer" aria-label="AV Beat on Twitter/X"
className="text-[#475569] hover:text-[#1D4ED8] transition-colors social-icon">
<TwitterXIcon size={17} />
</a>
<a href="https://facebook.com/avbeat" target="_blank" rel="noopener noreferrer" aria-label="AV Beat on Facebook"
className="text-[#475569] hover:text-[#1D4ED8] transition-colors social-icon">
<FacebookIcon size={17} />
</a>
</div>
<div className="flex items-center gap-4 text-xs font-body text-[#475569]">
<Link href="/privacy" className="hover:text-[#1D4ED8] transition-colors">Privacy Policy</Link>
<Link href="/terms" className="hover:text-[#1D4ED8] transition-colors">Terms of Service</Link>
<span className="inline-flex flex-col items-end gap-0.5 text-right"><span className="text-xs">© 2026 JTR Media Properties. All Rights Reserved.</span><span className="text-[8px] opacity-70 leading-none">Media representation by Relevant Media Properties</span></span>
</div>
</div>
</div>
</footer>);
}

View File

@@ -0,0 +1,105 @@
'use client';
import { Suspense, useEffect, useRef } from 'react';
import { usePathname, useSearchParams } from 'next/navigation';
const ENDPOINT =
(typeof process !== 'undefined' && process.env.NEXT_PUBLIC_RMP_ADS_API
? process.env.NEXT_PUBLIC_RMP_ADS_API
: 'https://advertising.relevantmediaproperties.com') + '/api/analytics/pageview';
const ARTICLE_PREFIXES = ['/news/', '/articles/', '/article/', '/blog/'];
const SKIP_PREFIXES = ['/admin', '/api', '/login', '/client-login', '/wp-admin', '/marketplace/admin'];
function getSessionId(): string {
try {
let sid = sessionStorage.getItem('rmp_analytics_sid');
if (!sid) {
sid =
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `as_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
sessionStorage.setItem('rmp_analytics_sid', sid);
}
return sid;
} catch {
return `as_${Date.now()}`;
}
}
function utmParams(): Record<string, string | null> {
if (typeof window === 'undefined') return {};
const p = new URLSearchParams(window.location.search);
const out: Record<string, string | null> = {};
for (const k of ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term']) {
out[k] = p.get(k);
}
return out;
}
function fireView(propertySlug: string, pathname: string) {
if (typeof window === 'undefined') return;
if (SKIP_PREFIXES.some((p) => pathname.startsWith(p))) return;
const isArticle = ARTICLE_PREFIXES.some((p) => pathname.startsWith(p));
const articleSlug = isArticle ? pathname.split('/').filter(Boolean).pop() || null : null;
const utm = utmParams();
const payload = JSON.stringify({
propertySlug,
pagePath: pathname,
pageUrl: window.location.href,
articleSlug,
referrer: document.referrer || null,
sessionId: getSessionId(),
eventType: 'view',
utmSource: utm.utm_source,
utmMedium: utm.utm_medium,
utmCampaign: utm.utm_campaign,
utmContent: utm.utm_content,
utmTerm: utm.utm_term,
});
try {
if (typeof navigator.sendBeacon === 'function') {
const ok = navigator.sendBeacon(ENDPOINT, new Blob([payload], { type: 'application/json' }));
if (ok) return;
}
} catch {
/* fall through */
}
fetch(ENDPOINT, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: payload,
keepalive: true,
mode: 'cors',
}).catch(() => undefined);
}
function BeaconInner({ propertySlug }: { propertySlug: string }) {
const pathname = usePathname() || '';
const searchParams = useSearchParams();
const lastKey = useRef('');
useEffect(() => {
if (!propertySlug || !pathname) return;
const key = `${pathname}?${searchParams?.toString() || ''}`;
if (lastKey.current === key) return;
lastKey.current = key;
fireView(propertySlug, pathname);
}, [pathname, searchParams, propertySlug]);
return null;
}
/** Mount in root layout — tracks all public routes → advertising pageview API → adv.page_view_events. */
export default function SiteAnalyticsBeacon({ propertySlug }: { propertySlug: string }) {
if (!propertySlug) return null;
return (
<Suspense fallback={null}>
<BeaconInner propertySlug={propertySlug} />
</Suspense>
);
}

View File

@@ -35,33 +35,39 @@ let CACHE: CacheRow | null = null;
let REFRESH_IN_FLIGHT: Promise<void> | null = null; let REFRESH_IN_FLIGHT: Promise<void> | null = null;
const FALLBACK: Ad[] = [ const FALLBACK: Ad[] = [
{ slug: "liveu-728x90", label: "LiveU 728x90", size: "728x90", { slug: "liveu-728x90",
campaign_id: "6031b0e3-2eee-4974-834f-c5c71b339aae", label: "LiveU 728x90", size: "728x90",
src: "/legacy/ads/728-x-90-pxEN.gif", src: "/legacy/ads/728-x-90-pxEN.gif",
alt: "LiveU 728x90", click_url: "https://bit.ly/4ghXVeq" }, click_url: "https://advertising.relevantmediaproperties.com/r/6031b0e3-2eee-4974-834f-c5c71b339aae",
{ slug: "blackmagic-davinci-resolve-300x600", { slug: "blackmagic-davinci-resolve-300x600",
campaign_id: "1d7aecbc-39ec-4e2f-a10f-b5394a9eaf27",
label: "Blackmagic DaVinci Resolve 20", size: "300x600", label: "Blackmagic DaVinci Resolve 20", size: "300x600",
src: "/legacy/ads/Davinci-Resolve-20_300x600.jpg", src: "/legacy/ads/Davinci-Resolve-20_300x600.jpg",
alt: "Blackmagic Design DaVinci Resolve 20", alt: "Blackmagic Design DaVinci Resolve 20",
click_url: "http://bmd.link/mRNsKu" }, click_url: "https://advertising.relevantmediaproperties.com/r/1d7aecbc-39ec-4e2f-a10f-b5394a9eaf27",
{ slug: "magewell-pro-convert-300x250", label: "Magewell Pro-Convert", size: "300x250", { slug: "magewell-pro-convert-300x250",
campaign_id: "47f19e71-fc97-4aa5-998d-97e780b57729", label: "Magewell Pro-Convert", size: "300x250",
src: "/legacy/ads/Magewell_Pro-Convert-IP-to-HDMI_Sports_NAB2026.gif", src: "/legacy/ads/Magewell_Pro-Convert-IP-to-HDMI_Sports_NAB2026.gif",
alt: "Magewell Pro-Convert IP to HDMI", alt: "Magewell Pro-Convert IP to HDMI",
click_url: "https://www.magewell.com" }, click_url: "https://advertising.relevantmediaproperties.com/r/47f19e71-fc97-4aa5-998d-97e780b57729",
{ slug: "liveu-payg-300x250", label: "LiveU PAYG", size: "300x250", { slug: "liveu-payg-300x250", label: "LiveU PAYG", size: "300x250",
src: "/legacy/ads/PAYG-300x250-1.gif", src: "/legacy/ads/PAYG-300x250-1.gif",
alt: "LiveU Pay-As-You-Go", click_url: "https://bit.ly/4ghXVeq" }, alt: "LiveU Pay-As-You-Go", click_url: "https://bit.ly/4ghXVeq" },
{ slug: "aja-colorbox-300x250", label: "AJA ColorBox", size: "300x250", { slug: "aja-colorbox-300x250",
campaign_id: "75157edc-df21-460e-b08c-e0c8d8cfdf5b", label: "AJA ColorBox", size: "300x250",
src: "/legacy/ads/aja_2025_colorbox_og_colorbox_300x250_en-2.gif", src: "/legacy/ads/aja_2025_colorbox_og_colorbox_300x250_en-2.gif",
alt: "AJA ColorBox", click_url: "https://www.aja.com" }, click_url: "https://advertising.relevantmediaproperties.com/r/75157edc-df21-460e-b08c-e0c8d8cfdf5b",
{ slug: "zixi-300x250", label: "Zixi", size: "300x250", { slug: "zixi-300x250", label: "Zixi", size: "300x250",
src: "/legacy/ads/Zixi_Ads_300x250.png", src: "/legacy/ads/Zixi_Ads_300x250.png",
alt: "Zixi", click_url: "https://zixi.com/" }, alt: "Zixi", click_url: "https://zixi.com/" },
{ slug: "telycam-300x250", label: "Telycam MixOne / ExploreXE", size: "300x250", { slug: "telycam-300x250",
campaign_id: "d4fda1cd-1ffc-4ae5-bec3-1b38cf62a5e9", label: "Telycam MixOne / ExploreXE", size: "300x250",
src: "/legacy/ads/Telycam_BroadcastBeat_300x250_MixOne-ExploreXE_with_NAB2026_2f30157f.gif", src: "/legacy/ads/Telycam_BroadcastBeat_300x250_MixOne-ExploreXE_with_NAB2026_2f30157f.gif",
alt: "Telycam MixOne / ExploreXE — 2026 NAB Show", click_url: "https://telycam.com/" }, click_url: "https://advertising.relevantmediaproperties.com/r/d4fda1cd-1ffc-4ae5-bec3-1b38cf62a5e9",
{ slug: "lectrosonics-300x250", label: "Lectrosonics", size: "300x250", { slug: "lectrosonics-300x250",
campaign_id: "e69cb845-5526-4165-bca1-964104b70c6b", label: "Lectrosonics", size: "300x250",
src: "/legacy/ads/300x250-banner-Our-Story-film_resize.jpg", src: "/legacy/ads/300x250-banner-Our-Story-film_resize.jpg",
alt: "Lectrosonics Our Story", click_url: "https://www.lectrosonics.com/" }, click_url: "https://advertising.relevantmediaproperties.com/r/e69cb845-5526-4165-bca1-964104b70c6b",
]; ];
interface DbRow { interface DbRow {