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

@@ -1,41 +1,29 @@
# Pending Ad Creatives — upload to public/legacy/ads/ # Pending Ad Creatives
Images below were NOT recovered from the Wayback Machine (Wayback returned The active banner pool is defined in `src/lib/ads.ts`. Banners with an
HTML overlays instead of raw files). Ryan needs to supply these from local `adsanity_id` render via the Adsanity iframe at
storage or the original advertiser. `https://ads.broadcastbeat.com/wp-content/plugins/adsanity/render.php?id=...`
and need no local file. Banners with `src` need the file in
`public/legacy/ads/`.
## 728x90 Leaderboard (1 missing) ## Adsanity campaign IDs to verify in WordPress admin
- `Matrox_728x90_ConvertIP_Series_0722_7b13be6a.jpg`Matrox ConvertIP Series - **Studio Hero** (currently `ad-162714`)that ID was originally mapped
Click URL: https://info.matrox.com/video/ad/infrastructure/broadcast/convertip-series to "Studio Suite"; confirm the Hero campaign exists in Adsanity and
update `ads.ts` if a different ID is correct.
- **Blackmagic 300x600** (currently `ad-210571`) — that ID is also used by
the Riedel campaign in the historical map; confirm the ATEM 4 M/E
campaign has its own ID.
## 300x600 Fixed Sidebar (1 missing — highest priority) ## Optional local-file upgrades
- `ATEM-4ME-Constellation-4K-Plus-v1g-300x600-EN_1804ac7b.jpg` — Blackmagic ATEM 4 M/E These banners currently render via Adsanity iframe. To swap to a faster
Click URL: http://bmd.link/mRNsKu local render, drop the image into `public/legacy/ads/` and add `src` +
Note: SidebarAdStack will show nothing in the 300x600 slot until this is added. `alt` + `click_url` to the corresponding entry in `src/lib/ads.ts`.
## 300x250 Rotating Sidebar (20 missing) - AJA Video 300x250
- `Magewell_Director_One_Pro-Convert-IP-to-HDMI_Sports_InfoComm_9170d5cd.gif` — Magewell Director One - Zixi 300x250
- `300x250-banner-Our-Story-film_resize_c30978de.jpg` Lectrosonics Our Story - Lectrosonics 300x250
- `Zixi_Ads_300x250_93cf5742.png` — Zixi - Opengear 300x250
- `Studio-Hero-for-Broadcast-Beat_e1aaa706.png` Studio Hero - Studio Hero 300x250
- `aja_2025_colorbox_og_colorbox_300x250_en-3_041eaa50.gif` — AJA ColorBox 2025 - Magewell 300x250
- `1467-NAB-300x250-R2V1-1_364334f9.png` — Dell at NAB - Blackmagic 300x600 — Wayback target file:
- `Broadcasters-report-v2-300x250-px_9b98a4c3.jpg` — JW Player Broadcasters Report `ATEM-4ME-Constellation-4K-Plus-v1g-300x600-EN.jpg`, link http://bmd.link/mRNsKu
- `HPA-ad-Oct-22_44905037.png` — Puget Systems HPA
- `Broadcast-Beat-April-Banner-V1_160b8d08.jpg` — ROE Visual
- `webinar-ad_19d5c0c1.png` — Viz Flowics 2023 Webinar
- `BB_NAB1_022924-15_f127d83a.gif` — BOXX at NAB
- `October-November-ad-Broadcast-Beat-TAG-Monitoring-Layers-blo_9d37b415.jpg` — TAG Video Systems
- `Broadcast-Beat-Banner-March-2024-300x250-1_6dda0fb9.jpg` — Camplex SMPTE
- `GIF3_07_10fps_03_71177a21.gif` — BOXX animated
- `300x250_Matrox_Monarch_EDGE_Series_Flex_Your_Workflow_0924_caf31161.jpg` — Matrox Monarch EDGE
- `quantum-webinar_8db637f8.jpg` — Quantum Webinar
- `openGear-guide-2026-300x250-1_a5c4b515.png` — openGear Guide 2026
- `250417-300-x-250-ad_4e4ea2cf.png` — OpenDrives NAB 2025
- `image001-1-1_d6f7d0df.png` — Look Solutions Tiny S
- `300x250-Bolero-Mini_4c8e5038.gif` — Riedel Bolero Mini
## How to restore
1. Drop each file into `public/legacy/ads/` in the broadcastbeat repo
2. Re-add the entry to the appropriate array in `src/lib/ads.ts`
3. Push and Coolify will redeploy automatically

View File

@@ -900,10 +900,10 @@ export default function ArticleFeed() {
)} )}
</div> </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"> <aside className="lg:col-span-4" aria-label="Advertisements">
<div className="lg:sticky lg:top-24"> <div className="lg:sticky lg:top-24">
<SidebarAdStack count={5} /> <SidebarAdStack />
</div> </div>
</aside> </aside>
</div> </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>
</div> </div>
{/* Top leaderboard */}
<div className="max-w-container mx-auto px-4 pt-6">
<AdSlot zone="article-top" size="728x90" />
</div>
{/* Article */} {/* Article */}
<article className="max-w-container mx-auto px-4 py-8 md:py-12"> <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"> <div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
@@ -453,8 +448,8 @@ export default function NewsArticleDetailClient({
{/* Sidebar */} {/* Sidebar */}
<aside className="lg:col-span-4 space-y-6"> <aside className="lg:col-span-4 space-y-6">
{/* Blackmagic 300x600 fixed at top + rotating 300x250 banners */} {/* Blackmagic 300x600 fixed at top + every 300x250 banner stacked */}
<SidebarAdStack count={4} /> <SidebarAdStack />
{/* Related Articles Sidebar */} {/* Related Articles Sidebar */}
<div className="bg-[#111] border border-[#222] p-5"> <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'; import { createClient } from '@/lib/supabase/client';
interface Ad { interface Ad {
src: string;
label: string; label: string;
size: string; size: string;
src?: string;
alt?: string;
click_url?: string; click_url?: string;
adsanity_id?: 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 }) { export default function AdImage({ ad, page, priority }: { ad: Ad; page?: string; priority?: boolean }) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const trackedRef = useRef(false); const trackedRef = useRef(false);
const supabase = createClient(); const supabase = createClient();
const adsanityId = ad.adsanity_id || ADSANITY_CAMPAIGN_MAP[ad.label.toLowerCase().replace(/\s+/g, '-')] || ''; const adsanityId = ad.adsanity_id || '';
useEffect(() => { useEffect(() => {
const observer = new IntersectionObserver(([entry]) => { 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 ( return (
<div ref={containerRef} className="ad-container"> <div ref={containerRef} className="ad-container">
{ad.click_url ? ( {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> </div>
); );

View File

@@ -7,6 +7,8 @@ import {
TwitterXIcon, TwitterXIcon,
FacebookIcon } from FacebookIcon } from
"@/components/ui/Icons"; "@/components/ui/Icons";
import AdImage from "@/components/AdImage";
import { ADS_728X90, shuffle } from "@/lib/ads";
// DO NOT OVERRIDE — Footer column structure and link targets // DO NOT OVERRIDE — Footer column structure and link targets
const footerColumns = [ const footerColumns = [
@@ -61,20 +63,13 @@ const footerColumns = [
export default function Footer() { export default function Footer() {
return ( return (
<footer className="bg-[#0d0d0d] border-t border-[#222]"> <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="max-w-container mx-auto px-4 pt-8 pb-4">
<div className="flex flex-wrap justify-center gap-4 mb-8"> {ADS_728X90.length > 0 && (
{/* DO NOT OVERRIDE — footer ad placeholder */} <div className="flex justify-center mb-8">
<div className="ad-placeholder w-[300px] h-[250px]"> <AdImage ad={shuffle(ADS_728X90)[0]} page="footer" />
<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" />
</div> </div>
</div> )}
{/* Multi-column link section */} {/* Multi-column link section */}
<div className="border-t border-[#222] pt-8 pb-6 grid grid-cols-2 md:grid-cols-4 gap-8"> <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"; "use client";
import { useMemo } from "react"; import { useMemo } from "react";
import AdImage from "./AdImage"; 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({ export default function SidebarAdStack({
count = 5,
excludeSrcs = [], excludeSrcs = [],
className = "", className = "",
}: { }: {
count?: number;
excludeSrcs?: string[]; excludeSrcs?: string[];
className?: string; className?: string;
}) { }) {
const rotating = useMemo<Ad[]>( const rotating = useMemo<Ad[]>(
() => pickAds("300x250", count, new Set(excludeSrcs)), () => rotateAll("300x250", new Set(excludeSrcs)),
[count, excludeSrcs.join("|")], [excludeSrcs.join("|")],
); );
return ( return (
<div className={`space-y-4 ${className}`}> <div className={`space-y-4 ${className}`}>
{FIXED_300X600 && <AdImage ad={FIXED_300X600} priority />} {FIXED_300X600 && <AdImage ad={FIXED_300X600} priority />}
{rotating.map((ad) => ( {rotating.map((ad) => (
<AdImage key={ad.src} ad={ad} /> <AdImage key={ad.src ?? ad.adsanity_id ?? ad.label} ad={ad} />
))} ))}
</div> </div>
); );

View File

@@ -1,32 +1,92 @@
// Ad creatives with verified image files in public/legacy/ads/. // Banner control panel: this file is the source of truth for which ads
// Only ads with a real downloaded image are included — entries without // render on broadcastbeat.com. Adding a new entry here automatically
// a recovered file have been moved to PENDING.md for Ryan to upload. // rotates it into the appropriate zone — no other code changes needed.
// Missing: Matrox 728x90, Blackmagic 300x600, and 20 of 22 300x250s. //
// 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 { export interface Ad {
src: string;
alt: string;
label: string; label: string;
size: "300x250" | "300x600" | "728x90"; 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[] = [ 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" }, label: "LiveU",
{ src: "/legacy/ads/728-x-90-pxEN_3ce6f379.gif", alt: "LiveU 728x90", label: "LiveU", size: "728x90", click_url: "https://bit.ly/4ghXVeq" }, 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[] = [ 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. * 300x600 fixed sidebar slot. Rendered at the TOP of SidebarAdStack and is
export const FIXED_300X600: Ad | null = null; * 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. */ /** Fisher-Yates shuffle, returns a new array. */
export function shuffle<T>(arr: T[]): T[] { 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. * Return ALL ads of the requested size in a shuffled order. Use this when
* Pass `exclude` (set of `src` strings already used elsewhere on the same page) * every banner must render (e.g. the sidebar 300x250 stack); the shuffle
* to guarantee no duplicates across slots. * 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( export function pickAds(
size: Ad["size"], size: Ad["size"],
n: number, n: number,
exclude: Set<string> = new Set(), exclude: Set<string> = new Set(),
): Ad[] { ): Ad[] {
const pool = const all = rotateAll(size, exclude);
size === "300x250" ? ADS_300X250 return all.slice(0, Math.min(n, all.length));
: size === "728x90" ? ADS_728X90
: [];
const filtered = pool.filter((a) => !exclude.has(a.src));
return shuffle(filtered).slice(0, Math.min(n, filtered.length));
} }