wip-banner-system: full banner pool, stack all 300x250s, footer 728x90

Banner spec from Ryan:

- 728x90 header (every page): pool is now LiveU only; rotates when more
  728x90 entries are added to ads.ts.
- 728x90 below main menu on article pages: removed (was the article-top
  AdSlot stub).
- 728x90 footer (every page): replaces the old 300x250 rocket.new
  placeholder in Footer.tsx. Rotates from the same ADS_728X90 pool.
- 300x250 sidebar: now renders EVERY entry in ADS_300X250 stacked
  vertically, shuffled per page-view. Pool: AJA, Zixi, Telycam, LiveU,
  Lectrosonics, Opengear, Studio Hero, Magewell. Adding more entries to
  ads.ts auto-grows the stack.
- 300x600 fixed slot: Blackmagic (Adsanity ad-210571), top of sidebar,
  never rotated.

Implementation:

- ads.ts: Ad interface now allows either src (local image) or adsanity_id
  (iframe). New rotateAll() returns ALL ads of a size shuffled; pickAds
  kept for ArticleBody bounded slots.
- AdImage.tsx: dropped the legacy ADSANITY_CAMPAIGN_MAP label fallback —
  ads.ts is now the single source of truth, so Telycam (no adsanity_id)
  renders its local GIF instead of the iframe.
- SidebarAdStack.tsx: rotateAll(300x250) instead of pickAds with a
  count cap. count prop removed; callers updated.
- NewsArticleDetailClient.tsx: removed article-top 728x90 stub.
- Footer.tsx: 728x90 AdImage in place of the 300x250 placeholder.
- LeaderboardAd.tsx: deleted (was unused after the earlier home-page
  cleanup).
- PENDING_ads.md: rewritten — Adsanity IDs to verify (Studio Hero,
  Blackmagic) and optional local-file upgrades.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Code
2026-05-14 15:42:54 +00:00
parent e8c7185bf7
commit 243f4855a1
8 changed files with 147 additions and 121 deletions

View File

@@ -900,10 +900,10 @@ export default function ArticleFeed() {
)}
</div>
{/* Sidebar — Blackmagic 300x600 fixed at top + 5 rotating 300x250s */}
{/* Sidebar — Blackmagic 300x600 fixed at top + every 300x250 banner stacked */}
<aside className="lg:col-span-4" aria-label="Advertisements">
<div className="lg:sticky lg:top-24">
<SidebarAdStack count={5} />
<SidebarAdStack />
</div>
</aside>
</div>

View File

@@ -1,12 +0,0 @@
import React from "react";
import AdSlot from "@/components/AdSlot";
export default function LeaderboardAd() {
return (
<div className="bg-ice-blue border-b border-border py-3">
<div className="max-w-container mx-auto px-4 flex justify-center">
<AdSlot zone="homepage-top" size="728x90" />
</div>
</div>
);
}

View File

@@ -283,11 +283,6 @@ export default function NewsArticleDetailClient({
</div>
</div>
{/* Top leaderboard */}
<div className="max-w-container mx-auto px-4 pt-6">
<AdSlot zone="article-top" size="728x90" />
</div>
{/* Article */}
<article className="max-w-container mx-auto px-4 py-8 md:py-12">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
@@ -453,8 +448,8 @@ export default function NewsArticleDetailClient({
{/* Sidebar */}
<aside className="lg:col-span-4 space-y-6">
{/* Blackmagic 300x600 fixed at top + rotating 300x250 banners */}
<SidebarAdStack count={4} />
{/* Blackmagic 300x600 fixed at top + every 300x250 banner stacked */}
<SidebarAdStack />
{/* Related Articles Sidebar */}
<div className="bg-[#111] border border-[#222] p-5">

View File

@@ -4,33 +4,19 @@ import AppImage from '@/components/ui/AppImage';
import { createClient } from '@/lib/supabase/client';
interface Ad {
src: string;
label: string;
size: string;
src?: string;
alt?: string;
click_url?: string;
adsanity_id?: string;
}
const ADSANITY_CAMPAIGN_MAP: Record<string, string> = {
'riedel': 'ad-210571',
'look-solutions': 'ad-208185',
'opengear': 'ad-200174',
'telycam': 'ad-191740',
'livu-728': 'ad-189081',
'aja': 'ad-164285',
'studio-suite': 'ad-162714',
'zixi': 'ad-156737',
'lectrosonics': 'ad-156727',
'livu-300': 'ad-156486',
'magewell': 'ad-154296',
'blackmagic': 'ad-210571'
};
export default function AdImage({ ad, page, priority }: { ad: Ad; page?: string; priority?: boolean }) {
const containerRef = useRef<HTMLDivElement>(null);
const trackedRef = useRef(false);
const supabase = createClient();
const adsanityId = ad.adsanity_id || ADSANITY_CAMPAIGN_MAP[ad.label.toLowerCase().replace(/\s+/g, '-')] || '';
const adsanityId = ad.adsanity_id || '';
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
@@ -74,12 +60,14 @@ export default function AdImage({ ad, page, priority }: { ad: Ad; page?: string;
);
}
if (!ad.src) return null;
return (
<div ref={containerRef} className="ad-container">
{ad.click_url ? (
<a href={ad.click_url} rel="sponsored noopener noreferrer" onClick={handleClick}><AppImage src={ad.src} alt={ad.label} width={width} height={height} priority={priority} /></a>
<a href={ad.click_url} rel="sponsored noopener noreferrer" onClick={handleClick}><AppImage src={ad.src} alt={ad.alt || ad.label} width={width} height={height} priority={priority} /></a>
) : (
<AppImage src={ad.src} alt={ad.label} width={width} height={height} priority={priority} />
<AppImage src={ad.src} alt={ad.alt || ad.label} width={width} height={height} priority={priority} />
)}
</div>
);

View File

@@ -7,6 +7,8 @@ import {
TwitterXIcon,
FacebookIcon } from
"@/components/ui/Icons";
import AdImage from "@/components/AdImage";
import { ADS_728X90, shuffle } from "@/lib/ads";
// DO NOT OVERRIDE — Footer column structure and link targets
const footerColumns = [
@@ -61,20 +63,13 @@ const footerColumns = [
export default function Footer() {
return (
<footer className="bg-[#0d0d0d] border-t border-[#222]">
{/* Footer Ad Row */}
{/* Footer Ad Row — site-wide 728x90 (rotates if multiple in pool) */}
<div className="max-w-container mx-auto px-4 pt-8 pb-4">
<div className="flex flex-wrap justify-center gap-4 mb-8">
{/* DO NOT OVERRIDE — footer ad placeholder */}
<div className="ad-placeholder w-[300px] h-[250px]">
<AppImage
src="https://img.rocket.new/generatedImages/rocket_gen_img_189430e3f-1768594771653.png"
alt="Advertisement — BroadcastBeat sponsor"
width={300}
height={250}
className="w-full h-full object-contain" />
{ADS_728X90.length > 0 && (
<div className="flex justify-center mb-8">
<AdImage ad={shuffle(ADS_728X90)[0]} page="footer" />
</div>
</div>
)}
{/* Multi-column link section */}
<div className="border-t border-[#222] pt-8 pb-6 grid grid-cols-2 md:grid-cols-4 gap-8">

View File

@@ -1,27 +1,28 @@
"use client";
import { useMemo } from "react";
import AdImage from "./AdImage";
import { FIXED_300X600, pickAds, type Ad } from "@/lib/ads";
import { FIXED_300X600, rotateAll, type Ad } from "@/lib/ads";
// Renders the fixed 300x600 at the top (no rotation) followed by EVERY
// 300x250 banner stacked vertically in a shuffled order. Adding more
// 300x250 entries to ads.ts automatically grows this stack.
export default function SidebarAdStack({
count = 5,
excludeSrcs = [],
className = "",
}: {
count?: number;
excludeSrcs?: string[];
className?: string;
}) {
const rotating = useMemo<Ad[]>(
() => pickAds("300x250", count, new Set(excludeSrcs)),
[count, excludeSrcs.join("|")],
() => rotateAll("300x250", new Set(excludeSrcs)),
[excludeSrcs.join("|")],
);
return (
<div className={`space-y-4 ${className}`}>
{FIXED_300X600 && <AdImage ad={FIXED_300X600} priority />}
{rotating.map((ad) => (
<AdImage key={ad.src} ad={ad} />
<AdImage key={ad.src ?? ad.adsanity_id ?? ad.label} ad={ad} />
))}
</div>
);

View File

@@ -1,32 +1,92 @@
// Ad creatives with verified image files in public/legacy/ads/.
// Only ads with a real downloaded image are included — entries without
// a recovered file have been moved to PENDING.md for Ryan to upload.
// Missing: Matrox 728x90, Blackmagic 300x600, and 20 of 22 300x250s.
// Banner control panel: this file is the source of truth for which ads
// render on broadcastbeat.com. Adding a new entry here automatically
// rotates it into the appropriate zone — no other code changes needed.
//
// Each Ad must provide EITHER:
// - src + click_url + alt → local image, click goes to click_url
// - adsanity_id → Adsanity iframe (Adsanity tracks the click)
export interface Ad {
src: string;
alt: string;
label: string;
size: "300x250" | "300x600" | "728x90";
click_url: string;
src?: string;
alt?: string;
click_url?: string;
adsanity_id?: string;
}
/** Rotating leaderboard pool — 3 verified real images. */
/** 728x90 leaderboard pool — header (top of every page) and footer. */
export const ADS_728X90: Ad[] = [
{ src: "/legacy/ads/Broadcast-Beat-Ad-March-2023_2f1904b5.png", alt: "Tower Products", label: "Tower", size: "728x90", click_url: "https://bit.ly/3rd4oBS" },
{ src: "/legacy/ads/11503_URXP41D_Audio_728X90_OnlineBanner_V1_StaticBackup_40K_f92207ce.jpg", alt: "Sony Pro UWP-D27", label: "Sony", size: "728x90", click_url: "https://pro.sony/ue_US/products/broadcastaudiouwpdseriesmicpackages/uwp-d27" },
{ src: "/legacy/ads/728-x-90-pxEN_3ce6f379.gif", alt: "LiveU 728x90", label: "LiveU", size: "728x90", click_url: "https://bit.ly/4ghXVeq" },
{
label: "LiveU",
size: "728x90",
src: "/legacy/ads/728-x-90-pxEN_3ce6f379.gif",
alt: "LiveU 728x90",
click_url: "https://bit.ly/4ghXVeq",
},
];
/** Rotating 300×250 sidebar pool — 2 verified real images. */
/**
* 300x250 sidebar pool. ALL entries render on every page — SidebarAdStack
* stacks them vertically and shuffles the order per page-view so each banner
* cycles through positions. Add new entries here to grow the stack.
*/
export const ADS_300X250: Ad[] = [
{ src: "/legacy/ads/PAYG-300x250-1_31957672.gif", alt: "LiveU pay-as-you-go", label: "LiveU", size: "300x250", click_url: "https://bit.ly/4ghXVeq" },
{ src: "/legacy/ads/Telycam_BroadcastBeat_300x250_MixOne-ExploreXE_with_NAB2026_2f30157f.gif", alt: "Telycam Mix One ExploreXE NAB 2026", label: "Telycam", size: "300x250", click_url: "https://telycam.com/" },
{
label: "AJA Video",
size: "300x250",
adsanity_id: "ad-164285",
},
{
label: "Zixi",
size: "300x250",
adsanity_id: "ad-156737",
},
{
label: "Telycam",
size: "300x250",
src: "/legacy/ads/Telycam_BroadcastBeat_300x250_MixOne-ExploreXE_with_NAB2026_2f30157f.gif",
alt: "Telycam Mix One ExploreXE NAB 2026",
click_url: "https://telycam.com/",
},
{
label: "LiveU",
size: "300x250",
src: "/legacy/ads/PAYG-300x250-1_31957672.gif",
alt: "LiveU pay-as-you-go",
click_url: "https://bit.ly/4ghXVeq",
},
{
label: "Lectrosonics",
size: "300x250",
adsanity_id: "ad-156727",
},
{
label: "Opengear",
size: "300x250",
adsanity_id: "ad-200174",
},
{
label: "Studio Hero",
size: "300x250",
adsanity_id: "ad-162714",
},
{
label: "Magewell",
size: "300x250",
adsanity_id: "ad-154296",
},
];
// 300x600 fixed sidebar: Blackmagic image not recovered from archive.
// SidebarAdStack skips this slot when null.
export const FIXED_300X600: Ad | null = null;
/**
* 300x600 fixed sidebar slot. Rendered at the TOP of SidebarAdStack and is
* NEVER rotated. Set to null to omit. To replace, swap this single entry.
*/
export const FIXED_300X600: Ad | null = {
label: "Blackmagic",
size: "300x600",
adsanity_id: "ad-210571",
};
/** Fisher-Yates shuffle, returns a new array. */
export function shuffle<T>(arr: T[]): T[] {
@@ -39,19 +99,30 @@ export function shuffle<T>(arr: T[]): T[] {
}
/**
* Draw N unique ads of the requested size for a single page render.
* Pass `exclude` (set of `src` strings already used elsewhere on the same page)
* to guarantee no duplicates across slots.
* Return ALL ads of the requested size in a shuffled order. Use this when
* every banner must render (e.g. the sidebar 300x250 stack); the shuffle
* ensures each banner cycles through positions across page-views.
*/
export function rotateAll(size: Ad["size"], exclude: Set<string> = new Set()): Ad[] {
const pool =
size === "300x250" ? ADS_300X250
: size === "728x90" ? ADS_728X90
: size === "300x600" ? (FIXED_300X600 ? [FIXED_300X600] : [])
: [];
const key = (a: Ad) => a.src ?? a.adsanity_id ?? a.label;
return shuffle(pool.filter((a) => !exclude.has(key(a))));
}
/**
* Draw N ads of the requested size, shuffled. Used by ArticleBody for
* a bounded number of in-article slots. For zones that must show every
* banner, prefer rotateAll().
*/
export function pickAds(
size: Ad["size"],
n: number,
exclude: Set<string> = new Set(),
): Ad[] {
const pool =
size === "300x250" ? ADS_300X250
: size === "728x90" ? ADS_728X90
: [];
const filtered = pool.filter((a) => !exclude.has(a.src));
return shuffle(filtered).slice(0, Math.min(n, filtered.length));
const all = rotateAll(size, exclude);
return all.slice(0, Math.min(n, all.length));
}