banners: refresh 8 creatives + bb.banner_creatives + /admin/banners

Backend:
  bb.banner_creatives (new) — image_path, click_url, size, start/end_date,
  status, adsanity_source_id. Seeded with 8 banners from broadcas_temp
  WP-export DB (post_type=ads): LiveU 728x90, Blackmagic DaVinci Resolve
  20 (300x600), Studio Hero, Magewell Pro-Convert, LiveU PAYG, AJA
  ColorBox, Zixi, Lectrosonics (all 300x250). Dates and click URLs come
  from the AdSanity rows; current creatives downloaded from Wayback
  snapshot 20260316123737.

src/lib/ads.ts:
  - Reads bb.banner_creatives via anon supabase-js client, gated by
    status='active' AND start<=NOW()<=end.
  - In-process 5-min TTL cache with stale-while-revalidate. Module load
    primes the cache; clients see fallback on first hit, live DB on
    subsequent.
  - Hardcoded FALLBACK list mirrors the seeded rows so the site keeps
    rendering banners if Supabase is unreachable during deploy.
  - Public helpers (rotateAll, pickAds, ADS_728X90, ADS_300X250,
    FIXED_300X600) remain synchronous so existing client component
    callsites work unchanged.

/admin/banners:
  Admin-only list + inline editor for every banner_creatives row:
  name, click URL, start/end date, status (active|scheduled|paused|
  expired). PATCH via /api/admin/banners/[id] — service_role bypass for
  the write, user_profiles.role admin/administrator for the gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude (Phase B)
2026-05-15 00:49:09 +00:00
parent 7d03a99215
commit 280d9818ba
12 changed files with 415 additions and 62 deletions

View File

@@ -1,10 +1,15 @@
// 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.
// Banner serving — source of truth is bb.banner_creatives in Supabase.
// The DB query is date-gated (status=active AND start<=NOW<=end) and the
// result is cached in-process for 5 minutes. A hardcoded fallback keeps
// the site rendering banners if Supabase is unreachable during a deploy
// or during the initial module load.
//
// Each Ad must provide a local image (src + click_url + alt). The legacy
// Adsanity iframe path was removed because ads.broadcastbeat.com no longer
// resolves; banners awaiting creatives are tracked in PENDING_ads.md.
// Public helpers (rotateAll, pickAds) are SYNCHRONOUS so existing client
// component callsites keep working. They read from a module-level cache
// that is primed on first import and refreshed asynchronously after the
// TTL expires.
import { createClient } from "@supabase/supabase-js";
export interface Ad {
label: string;
@@ -14,50 +19,107 @@ export interface Ad {
click_url?: string;
}
/** 728x90 leaderboard pool — header (between nav and ticker) and footer. */
export const ADS_728X90: Ad[] = [
{
label: "LiveU",
size: "728x90",
src: "/legacy/ads/728-x-90-pxEN_3ce6f379.gif",
alt: "LiveU 728x90",
click_url: "https://bit.ly/4ghXVeq",
},
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const SUPABASE_ANON = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const SCHEMA = process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || "bb";
const CACHE_TTL_MS = 5 * 60 * 1000;
type CacheRow = { rows: Ad[]; loadedAt: number };
let CACHE: CacheRow | null = null;
let REFRESH_IN_FLIGHT: Promise<void> | null = null;
const FALLBACK: Ad[] = [
{ label: "LiveU 728x90", size: "728x90", src: "/legacy/ads/728-x-90-pxEN.gif",
alt: "LiveU 728x90", click_url: "https://bit.ly/4ghXVeq" },
{ 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" },
{ label: "Studio Hero", size: "300x250", src: "/legacy/ads/Studio-Hero-for-Broadcast-Beat.png",
alt: "Studio Suite — Studio Hero", click_url: "http://www.TheStudioHero.com" },
{ 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" },
{ label: "LiveU PAYG", size: "300x250", src: "/legacy/ads/PAYG-300x250-1.gif",
alt: "LiveU Pay-As-You-Go", click_url: "https://bit.ly/4ghXVeq" },
{ 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" },
{ label: "Zixi", size: "300x250", src: "/legacy/ads/Zixi_Ads_300x250.png",
alt: "Zixi", click_url: "https://zixi.com/" },
{ label: "Lectrosonics", size: "300x250", src: "/legacy/ads/300x250-banner-Our-Story-film_resize.jpg",
alt: "Lectrosonics Our Story", click_url: "https://www.lectrosonics.com/" },
];
/**
* 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.
*
* AJA, Zixi, Lectrosonics, Opengear, Studio Hero, Magewell were removed
* pending real creatives (their Adsanity campaigns relied on
* ads.broadcastbeat.com, which is down). See PENDING_ads.md.
*/
export const ADS_300X250: Ad[] = [
{
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",
},
];
interface DbRow {
slug: string;
name: string;
image_path: string;
click_url: string | null;
size: "300x250" | "300x600" | "728x90";
}
/**
* 300x600 fixed sidebar slot. Rendered at the TOP of SidebarAdStack and is
* NEVER rotated. Set to null to omit. Blackmagic ATEM creative pending.
*/
export const FIXED_300X600: Ad | null = null;
async function loadFromDb(): Promise<Ad[]> {
if (!SUPABASE_URL || !SUPABASE_ANON) return FALLBACK;
try {
const sb = createClient(SUPABASE_URL, SUPABASE_ANON, {
db: { schema: SCHEMA as "public" },
auth: { persistSession: false },
});
const nowIso = new Date().toISOString();
const { data, error } = await sb
.from("banner_creatives")
.select("slug,name,image_path,click_url,size,start_date,end_date,status")
.eq("status", "active")
.or(`start_date.is.null,start_date.lte.${nowIso}`)
.or(`end_date.is.null,end_date.gte.${nowIso}`);
if (error || !data || data.length === 0) {
if (typeof console !== "undefined") {
console.warn("[ads] db query empty/error, using fallback:", error?.message || "no rows");
}
return FALLBACK;
}
return (data as DbRow[]).map((r) => ({
label: r.name,
size: r.size,
src: r.image_path,
alt: r.name,
click_url: r.click_url || undefined,
}));
} catch (err: any) {
if (typeof console !== "undefined") {
console.warn("[ads] db query threw, using fallback:", err?.message);
}
return FALLBACK;
}
}
function triggerRefresh(): void {
if (REFRESH_IN_FLIGHT) return;
REFRESH_IN_FLIGHT = loadFromDb()
.then((rows) => {
CACHE = { rows, loadedAt: Date.now() };
})
.catch(() => {
// Failures stay logged via loadFromDb; keep CACHE as-is.
})
.finally(() => {
REFRESH_IN_FLIGHT = null;
});
}
function readCachedAds(): Ad[] {
if (!CACHE) {
triggerRefresh();
return FALLBACK;
}
if (Date.now() - CACHE.loadedAt > CACHE_TTL_MS) {
// Stale — kick off a background refresh but serve the stale snapshot.
triggerRefresh();
}
return CACHE.rows;
}
/** Fisher-Yates shuffle, returns a new array. */
export function shuffle<T>(arr: T[]): T[] {
const out = arr.slice();
for (let i = out.length - 1; i > 0; i--) {
@@ -67,31 +129,26 @@ export function shuffle<T>(arr: T[]): T[] {
return out;
}
/**
* 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 pool = readCachedAds().filter((a) => a.size === size);
const key = (a: Ad) => a.src ?? 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(),
exclude: Set<string> = new Set()
): Ad[] {
const all = rotateAll(size, exclude);
return all.slice(0, Math.min(n, all.length));
}
// Sync exports for callers that still want the bare arrays. These also
// read the cache (so live edits surface) but fall back to FALLBACK.
export const ADS_728X90: Ad[] = (() => readCachedAds().filter((a) => a.size === "728x90"))();
export const ADS_300X250: Ad[] = (() => readCachedAds().filter((a) => a.size === "300x250"))();
export const FIXED_300X600: Ad | null = (() => readCachedAds().find((a) => a.size === "300x600") || null)();
// Prime the cache once at module load.
triggerRefresh();