feat(avbeat): tracked FALLBACK /r/ + SiteAnalyticsBeacon (PIN 4769)
This commit is contained in:
@@ -37,6 +37,7 @@ import CookieConsent from '@/components/CookieConsent';
|
||||
import NewsletterModal from '@/components/NewsletterModal';
|
||||
|
||||
import BannerTracking from '@/components/BannerTracking';
|
||||
import SiteAnalyticsBeacon from '@/components/SiteAnalyticsBeacon';
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1
|
||||
@@ -152,6 +153,7 @@ export default function RootLayout({
|
||||
</head>
|
||||
<body className="bg-brand-bg text-brand-text">
|
||||
<BannerTracking property="avbeat" />
|
||||
<SiteAnalyticsBeacon propertySlug="avbeat" />
|
||||
{/* Skip to main content — WCAG 2.4.1 */}
|
||||
<a
|
||||
href="#main-content"
|
||||
|
||||
@@ -13,27 +13,9 @@ interface Ad {
|
||||
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 =
|
||||
(process.env.NEXT_PUBLIC_RMP_ADS_API || "https://advertising.relevantmediaproperties.com")
|
||||
+ "/api/track/impression";
|
||||
(process.env.NEXT_PUBLIC_RMP_ADS_API || "https://advertising.relevantmediaproperties.com") +
|
||||
"/api/track/impression";
|
||||
|
||||
export default function AdImage({
|
||||
ad,
|
||||
@@ -46,33 +28,31 @@ export default function AdImage({
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const beaconMount = useRef(false);
|
||||
const lastViewportFire = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ad.src) return;
|
||||
const id = ad.campaign_id || ad.slug;
|
||||
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) {
|
||||
beaconMount.current = true;
|
||||
sendImpression(id, page, false);
|
||||
sendImpression(id, ad.slug, page, false);
|
||||
}
|
||||
}, [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));
|
||||
// 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}` : "");
|
||||
const clickHref = ad.click_url || (ad.campaign_id ? `/r/${ad.campaign_id}` : ad.slug ? `/r/${ad.slug}` : "");
|
||||
|
||||
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 ? (
|
||||
<a href={clickHref} target="_blank" rel="sponsored noopener noreferrer">
|
||||
<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;
|
||||
// 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({
|
||||
campaign_id: id,
|
||||
slug: slug || undefined,
|
||||
page_path: page || window.location.pathname,
|
||||
page_url: window.location.href,
|
||||
viewport,
|
||||
@@ -97,19 +81,16 @@ function sendImpression(id: string, page: string | undefined, viewport: boolean)
|
||||
try {
|
||||
const ok =
|
||||
typeof navigator.sendBeacon === "function" &&
|
||||
navigator.sendBeacon(
|
||||
RMP_TRACK_URL,
|
||||
new Blob([payload], { type: "application/json" }),
|
||||
);
|
||||
navigator.sendBeacon(RMP_TRACK_URL, new Blob([payload], { type: "application/json" }));
|
||||
if (ok) return;
|
||||
} catch {
|
||||
// fall through to fetch
|
||||
/* fall through */
|
||||
}
|
||||
fetch(RMP_TRACK_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: payload,
|
||||
keepalive: true,
|
||||
mode: "no-cors",
|
||||
mode: "cors",
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
|
||||
147
src/components/Footer.tsx.bak-jtr-footer-20260717
Normal file
147
src/components/Footer.tsx.bak-jtr-footer-20260717
Normal 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>);
|
||||
|
||||
}
|
||||
105
src/components/SiteAnalyticsBeacon.tsx
Normal file
105
src/components/SiteAnalyticsBeacon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -35,33 +35,39 @@ let CACHE: CacheRow | null = null;
|
||||
let REFRESH_IN_FLIGHT: Promise<void> | null = null;
|
||||
|
||||
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",
|
||||
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",
|
||||
campaign_id: "1d7aecbc-39ec-4e2f-a10f-b5394a9eaf27",
|
||||
label: "Blackmagic DaVinci Resolve 20", size: "300x600",
|
||||
src: "/legacy/ads/Davinci-Resolve-20_300x600.jpg",
|
||||
alt: "Blackmagic Design DaVinci Resolve 20",
|
||||
click_url: "http://bmd.link/mRNsKu" },
|
||||
{ slug: "magewell-pro-convert-300x250", label: "Magewell Pro-Convert", size: "300x250",
|
||||
click_url: "https://advertising.relevantmediaproperties.com/r/1d7aecbc-39ec-4e2f-a10f-b5394a9eaf27",
|
||||
{ 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",
|
||||
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",
|
||||
src: "/legacy/ads/PAYG-300x250-1.gif",
|
||||
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",
|
||||
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",
|
||||
src: "/legacy/ads/Zixi_Ads_300x250.png",
|
||||
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",
|
||||
alt: "Telycam MixOne / ExploreXE — 2026 NAB Show", click_url: "https://telycam.com/" },
|
||||
{ slug: "lectrosonics-300x250", label: "Lectrosonics", size: "300x250",
|
||||
click_url: "https://advertising.relevantmediaproperties.com/r/d4fda1cd-1ffc-4ae5-bec3-1b38cf62a5e9",
|
||||
{ slug: "lectrosonics-300x250",
|
||||
campaign_id: "e69cb845-5526-4165-bca1-964104b70c6b", label: "Lectrosonics", size: "300x250",
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user