218 lines
7.6 KiB
TypeScript
218 lines
7.6 KiB
TypeScript
// 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<void> | 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/<property>/<placement> 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<Ad[]> {
|
|
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<Ad[]> {
|
|
// 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<T>(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<string> = 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<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();
|