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
HTML overlays instead of raw files). Ryan needs to supply these from local
storage or the original advertiser.
The active banner pool is defined in `src/lib/ads.ts`. Banners with an
`adsanity_id` render via the Adsanity iframe at
`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)
- `Matrox_728x90_ConvertIP_Series_0722_7b13be6a.jpg`Matrox ConvertIP Series
Click URL: https://info.matrox.com/video/ad/infrastructure/broadcast/convertip-series
## Adsanity campaign IDs to verify in WordPress admin
- **Studio Hero** (currently `ad-162714`)that ID was originally mapped
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)
- `ATEM-4ME-Constellation-4K-Plus-v1g-300x600-EN_1804ac7b.jpg` — Blackmagic ATEM 4 M/E
Click URL: http://bmd.link/mRNsKu
Note: SidebarAdStack will show nothing in the 300x600 slot until this is added.
## Optional local-file upgrades
These banners currently render via Adsanity iframe. To swap to a faster
local render, drop the image into `public/legacy/ads/` and add `src` +
`alt` + `click_url` to the corresponding entry in `src/lib/ads.ts`.
## 300x250 Rotating Sidebar (20 missing)
- `Magewell_Director_One_Pro-Convert-IP-to-HDMI_Sports_InfoComm_9170d5cd.gif` — Magewell Director One
- `300x250-banner-Our-Story-film_resize_c30978de.jpg` Lectrosonics Our Story
- `Zixi_Ads_300x250_93cf5742.png` — Zixi
- `Studio-Hero-for-Broadcast-Beat_e1aaa706.png` Studio Hero
- `aja_2025_colorbox_og_colorbox_300x250_en-3_041eaa50.gif` — AJA ColorBox 2025
- `1467-NAB-300x250-R2V1-1_364334f9.png` — Dell at NAB
- `Broadcasters-report-v2-300x250-px_9b98a4c3.jpg` — JW Player Broadcasters Report
- `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
- AJA Video 300x250
- Zixi 300x250
- Lectrosonics 300x250
- Opengear 300x250
- Studio Hero 300x250
- Magewell 300x250
- Blackmagic 300x600 — Wayback target file:
`ATEM-4ME-Constellation-4K-Plus-v1g-300x600-EN.jpg`, link http://bmd.link/mRNsKu

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" />
</div>
{ADS_728X90.length > 0 && (
<div className="flex justify-center mb-8">
<AdImage ad={shuffle(ADS_728X90)[0]} page="footer" />
</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));
}