// 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. // // 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 { slug?: string; campaign_id?: string; label: string; size: "300x250" | "300x600" | "728x90"; src: string; alt: string; click_url?: string; } 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 | null = null; const FALLBACK: Ad[] = [ { slug: "liveu-728x90", label: "LiveU 728x90", size: "728x90", src: "/legacy/ads/728-x-90-pxEN.gif", alt: "LiveU 728x90", click_url: "https://bit.ly/4ghXVeq" }, { slug: "blackmagic-davinci-resolve-300x600", 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: "studio-hero-300x250", 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" }, { slug: "magewell-pro-convert-300x250", 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" }, { 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", src: "/legacy/ads/aja_2025_colorbox_og_colorbox_300x250_en-2.gif", alt: "AJA ColorBox", click_url: "https://www.aja.com" }, { 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", src: "/legacy/ads/Telycam_Broadcast Beat_300x250_MixOne-ExploreXE_with_NAB2026_2f30157f.gif", alt: "Telycam MixOne / ExploreXE — NAB 2026", click_url: "https://telycam.com/" }, { slug: "lectrosonics-300x250", label: "Lectrosonics", size: "300x250", src: "/legacy/ads/300x250-banner-Our-Story-film_resize.jpg", alt: "Lectrosonics Our Story", click_url: "https://www.lectrosonics.com/" }, ]; interface DbRow { slug: string; name: string; image_path: string; click_url: string | null; size: "300x250" | "300x600" | "728x90"; } // Centralized banner source: advertising.relevantmediaproperties.com // serves /api/banners// with CORS + 5min cache. // We fetch all three sizes once and merge. const RMP_API = process.env.NEXT_PUBLIC_RMP_ADS_API || "https://advertising.relevantmediaproperties.com"; const PROPERTY = process.env.NEXT_PUBLIC_RMP_PROPERTY || "broadcastbeat"; interface RmpBanner { campaign_id: string; name: string; image_url: string; image_webp_url?: string | null; click_url: string; width: number; height: number; placement: "300x250" | "300x600" | "728x90"; property: string; slug?: string; } async function fetchPlacement(size: Ad["size"]): Promise { try { const res = await fetch(`${RMP_API}/api/banners/${PROPERTY}/${size}`, { next: { revalidate: 300 }, }); if (!res.ok) return []; const data = await res.json() as { banners?: RmpBanner[] }; return (data.banners || []).map((b): Ad => ({ // Prefer the human-readable slug for the centralized click URL; fall back // to the campaign UUID if the slug column ever ends up empty. slug: b.slug || b.campaign_id, campaign_id: b.campaign_id, label: b.name, size: b.placement, src: b.image_url, alt: b.name, click_url: `https://www.relevantmediaproperties.com/${PROPERTY}/${b.slug || b.campaign_id}`, })); } catch { return []; } } async function loadFromDb(): Promise { // 1) Try centralized RMP source first (preferred path). try { const [a, b, c] = await Promise.all([ fetchPlacement("300x250"), fetchPlacement("300x600"), fetchPlacement("728x90"), ]); const all = [...a, ...b, ...c]; if (all.length > 0) return all; } catch { /* fall through */ } // 2) Legacy fallback: query BB's own bb.banner_creatives (kept until the // centralized RMP source is fully verified in prod). 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) return FALLBACK; return (data as DbRow[]).map((r) => ({ slug: r.slug, label: r.name, size: r.size, src: r.image_path, alt: r.name, click_url: r.click_url || undefined, })); } catch { 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; } export function shuffle(arr: T[]): T[] { const out = arr.slice(); for (let i = out.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [out[i], out[j]] = [out[j], out[i]]; } return out; } export function rotateAll(size: Ad["size"], exclude: Set = new Set()): Ad[] { 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)))); } export function pickAds( size: Ad["size"], n: number, exclude: Set = 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();