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>
BIN
public/legacy/ads/300x250-banner-Our-Story-film_resize.jpg
Normal file
|
After Width: | Height: | Size: 149 KiB |
BIN
public/legacy/ads/728-x-90-pxEN.gif
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/legacy/ads/Davinci-Resolve-20_300x600.jpg
Normal file
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 57 KiB |
BIN
public/legacy/ads/PAYG-300x250-1.gif
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
public/legacy/ads/Studio-Hero-for-Broadcast-Beat.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
public/legacy/ads/Zixi_Ads_300x250.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
public/legacy/ads/aja_2025_colorbox_og_colorbox_300x250_en-2.gif
Normal file
|
After Width: | Height: | Size: 121 KiB |
167
src/app/admin/banners/BannerRows.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
interface BannerRow {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
image_path: string;
|
||||||
|
click_url: string | null;
|
||||||
|
size: "300x250" | "300x600" | "728x90";
|
||||||
|
start_date: string | null;
|
||||||
|
end_date: string | null;
|
||||||
|
status: string;
|
||||||
|
adsanity_source_id: number | null;
|
||||||
|
notes: string | null;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(iso: string | null): string {
|
||||||
|
if (!iso) return "";
|
||||||
|
return iso.slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BannerRows({ banners }: { banners: BannerRow[] }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const [edits, setEdits] = useState<Record<string, Partial<BannerRow>>>({});
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [saved, setSaved] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function field(id: string, key: keyof BannerRow, value: any) {
|
||||||
|
setEdits((e) => ({ ...e, [id]: { ...e[id], [key]: value } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(id: string) {
|
||||||
|
setErr(null);
|
||||||
|
setSaved(null);
|
||||||
|
const patch = edits[id];
|
||||||
|
if (!patch || Object.keys(patch).length === 0) return;
|
||||||
|
const body: Record<string, any> = { ...patch };
|
||||||
|
if (body.start_date === "") body.start_date = null;
|
||||||
|
if (body.end_date === "") body.end_date = null;
|
||||||
|
const res = await fetch(`/api/admin/banners/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
setErr(j.error || `HTTP ${res.status}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaved(id);
|
||||||
|
setEdits((e) => {
|
||||||
|
const n = { ...e };
|
||||||
|
delete n[id];
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
startTransition(() => router.refresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{err && <div className="rounded border border-red-400 bg-red-900/30 px-4 py-3 text-sm text-red-200">{err}</div>}
|
||||||
|
|
||||||
|
{banners.map((b) => {
|
||||||
|
const dirty = !!edits[b.id];
|
||||||
|
const current: BannerRow = { ...b, ...(edits[b.id] || {}) };
|
||||||
|
return (
|
||||||
|
<div key={b.id} className="rounded border border-[#1f2937] bg-[#0b0f17] p-4">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="shrink-0">
|
||||||
|
<img
|
||||||
|
src={b.image_path}
|
||||||
|
alt={b.name}
|
||||||
|
style={{
|
||||||
|
width: b.size === "728x90" ? 320 : b.size === "300x600" ? 150 : 200,
|
||||||
|
height: "auto",
|
||||||
|
}}
|
||||||
|
className="rounded border border-[#1f2937]"
|
||||||
|
/>
|
||||||
|
<div className="mt-1 text-center text-xs text-[#6b7280]">{b.size}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
|
||||||
|
<label className="flex flex-col">
|
||||||
|
<span className="text-xs uppercase tracking-wider text-[#9ca3af] mb-1">Name</span>
|
||||||
|
<input
|
||||||
|
value={current.name}
|
||||||
|
onChange={(e) => field(b.id, "name", e.target.value)}
|
||||||
|
className="rounded border border-[#1f2937] bg-[#070a10] p-2 text-[#e5e7eb] focus:border-[#3b82f6] focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex flex-col">
|
||||||
|
<span className="text-xs uppercase tracking-wider text-[#9ca3af] mb-1">Click URL</span>
|
||||||
|
<input
|
||||||
|
value={current.click_url || ""}
|
||||||
|
onChange={(e) => field(b.id, "click_url", e.target.value)}
|
||||||
|
placeholder="https://…"
|
||||||
|
className="rounded border border-[#1f2937] bg-[#070a10] p-2 text-[#e5e7eb] focus:border-[#3b82f6] focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex flex-col">
|
||||||
|
<span className="text-xs uppercase tracking-wider text-[#9ca3af] mb-1">Start date</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={fmtDate(current.start_date)}
|
||||||
|
onChange={(e) => field(b.id, "start_date", e.target.value ? new Date(e.target.value).toISOString() : null)}
|
||||||
|
className="rounded border border-[#1f2937] bg-[#070a10] p-2 text-[#e5e7eb] focus:border-[#3b82f6] focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex flex-col">
|
||||||
|
<span className="text-xs uppercase tracking-wider text-[#9ca3af] mb-1">End date</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={fmtDate(current.end_date)}
|
||||||
|
onChange={(e) => field(b.id, "end_date", e.target.value ? new Date(e.target.value).toISOString() : null)}
|
||||||
|
className="rounded border border-[#1f2937] bg-[#070a10] p-2 text-[#e5e7eb] focus:border-[#3b82f6] focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex flex-col">
|
||||||
|
<span className="text-xs uppercase tracking-wider text-[#9ca3af] mb-1">Status</span>
|
||||||
|
<select
|
||||||
|
value={current.status}
|
||||||
|
onChange={(e) => field(b.id, "status", e.target.value)}
|
||||||
|
className="rounded border border-[#1f2937] bg-[#070a10] p-2 text-[#e5e7eb] focus:border-[#3b82f6] focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="active">active</option>
|
||||||
|
<option value="scheduled">scheduled</option>
|
||||||
|
<option value="paused">paused</option>
|
||||||
|
<option value="expired">expired</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex flex-col text-xs text-[#6b7280]">
|
||||||
|
<span className="uppercase tracking-wider text-[#9ca3af] mb-1">Image path</span>
|
||||||
|
<span className="font-mono break-all">{b.image_path}</span>
|
||||||
|
{b.adsanity_source_id && (
|
||||||
|
<span className="mt-1">AdSanity #{b.adsanity_source_id}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => save(b.id)}
|
||||||
|
disabled={!dirty || pending}
|
||||||
|
className="rounded bg-[#1d4ed8] px-3 py-1.5 text-sm font-medium text-white hover:bg-[#2563eb] disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Save changes
|
||||||
|
</button>
|
||||||
|
{saved === b.id && <span className="text-xs text-emerald-400">Saved</span>}
|
||||||
|
{dirty && saved !== b.id && <span className="text-xs text-amber-400">Unsaved</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
src/app/admin/banners/page.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { createAdminClient } from "@/lib/supabase/admin";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import BannerRows from "./BannerRows";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
interface BannerRow {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
image_path: string;
|
||||||
|
click_url: string | null;
|
||||||
|
size: "300x250" | "300x600" | "728x90";
|
||||||
|
start_date: string | null;
|
||||||
|
end_date: string | null;
|
||||||
|
status: string;
|
||||||
|
adsanity_source_id: number | null;
|
||||||
|
notes: string | null;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireAdmin() {
|
||||||
|
const sb = await createClient();
|
||||||
|
const { data: { user } } = await sb.auth.getUser();
|
||||||
|
if (!user) return { ok: false as const };
|
||||||
|
const { data: profile } = await sb
|
||||||
|
.from("user_profiles")
|
||||||
|
.select("role,is_active")
|
||||||
|
.eq("id", user.id)
|
||||||
|
.maybeSingle();
|
||||||
|
if (!profile?.is_active) return { ok: false as const };
|
||||||
|
if (!["administrator", "admin"].includes((profile as any).role)) return { ok: false as const };
|
||||||
|
return { ok: true as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminBannersPage() {
|
||||||
|
const guard = await requireAdmin();
|
||||||
|
if (!guard.ok) redirect("/admin");
|
||||||
|
|
||||||
|
const admin = createAdminClient();
|
||||||
|
const { data: rows } = await admin
|
||||||
|
.from("banner_creatives")
|
||||||
|
.select("*")
|
||||||
|
.order("size", { ascending: true })
|
||||||
|
.order("slug", { ascending: true });
|
||||||
|
|
||||||
|
const banners = (rows || []) as BannerRow[];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-6xl px-6 py-10 text-[#e5e7eb]">
|
||||||
|
<header className="mb-8 border-b border-[#222] pb-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Banner creatives</h1>
|
||||||
|
<p className="text-sm text-[#9ca3af] mt-1">
|
||||||
|
{banners.length} banners · live data from bb.banner_creatives. Edits
|
||||||
|
apply within 5 minutes (in-process cache TTL on the site renderer).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/admin" className="text-sm text-[#60a5fa] hover:underline">
|
||||||
|
← Admin home
|
||||||
|
</Link>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<BannerRows banners={banners} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
src/app/api/admin/banners/[id]/route.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
import { createAdminClient } from "@/lib/supabase/admin";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
async function requireAdmin() {
|
||||||
|
const sb = await createClient();
|
||||||
|
const { data: { user } } = await sb.auth.getUser();
|
||||||
|
if (!user) return { ok: false as const, status: 401, error: "Unauthorized" };
|
||||||
|
const { data: profile } = await sb
|
||||||
|
.from("user_profiles")
|
||||||
|
.select("role, is_active")
|
||||||
|
.eq("id", user.id)
|
||||||
|
.maybeSingle();
|
||||||
|
if (!profile?.is_active) return { ok: false as const, status: 403, error: "Inactive" };
|
||||||
|
if (!["administrator", "admin"].includes((profile as any).role)) {
|
||||||
|
return { ok: false as const, status: 403, error: "Insufficient permissions" };
|
||||||
|
}
|
||||||
|
return { ok: true as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALLOWED_FIELDS = new Set([
|
||||||
|
"name",
|
||||||
|
"click_url",
|
||||||
|
"image_path",
|
||||||
|
"size",
|
||||||
|
"start_date",
|
||||||
|
"end_date",
|
||||||
|
"status",
|
||||||
|
"notes",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const guard = await requireAdmin();
|
||||||
|
if (!guard.ok) return NextResponse.json({ error: guard.error }, { status: guard.status });
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const body = (await req.json()) as Record<string, any>;
|
||||||
|
const patch: Record<string, any> = {};
|
||||||
|
for (const [k, v] of Object.entries(body)) {
|
||||||
|
if (ALLOWED_FIELDS.has(k)) patch[k] = v;
|
||||||
|
}
|
||||||
|
if (Object.keys(patch).length === 0) {
|
||||||
|
return NextResponse.json({ error: "No allowed fields supplied" }, { status: 400 });
|
||||||
|
}
|
||||||
|
patch.updated_at = new Date().toISOString();
|
||||||
|
|
||||||
|
const admin = createAdminClient();
|
||||||
|
const { data, error } = await admin
|
||||||
|
.from("banner_creatives")
|
||||||
|
.update(patch)
|
||||||
|
.eq("id", id)
|
||||||
|
.select("id, slug, status")
|
||||||
|
.single();
|
||||||
|
if (error || !data) return NextResponse.json({ error: error?.message || "Not found" }, { status: 500 });
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, banner: data });
|
||||||
|
}
|
||||||
181
src/lib/ads.ts
@@ -1,10 +1,15 @@
|
|||||||
// Banner control panel: this file is the source of truth for which ads
|
// Banner serving — source of truth is bb.banner_creatives in Supabase.
|
||||||
// render on broadcastbeat.com. Adding a new entry here automatically
|
// The DB query is date-gated (status=active AND start<=NOW<=end) and the
|
||||||
// rotates it into the appropriate zone — no other code changes needed.
|
// 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
|
// Public helpers (rotateAll, pickAds) are SYNCHRONOUS so existing client
|
||||||
// Adsanity iframe path was removed because ads.broadcastbeat.com no longer
|
// component callsites keep working. They read from a module-level cache
|
||||||
// resolves; banners awaiting creatives are tracked in PENDING_ads.md.
|
// that is primed on first import and refreshed asynchronously after the
|
||||||
|
// TTL expires.
|
||||||
|
|
||||||
|
import { createClient } from "@supabase/supabase-js";
|
||||||
|
|
||||||
export interface Ad {
|
export interface Ad {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -14,50 +19,107 @@ export interface Ad {
|
|||||||
click_url?: string;
|
click_url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 728x90 leaderboard pool — header (between nav and ticker) and footer. */
|
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
export const ADS_728X90: Ad[] = [
|
const SUPABASE_ANON = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||||
{
|
const SCHEMA = process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || "bb";
|
||||||
label: "LiveU",
|
|
||||||
size: "728x90",
|
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||||
src: "/legacy/ads/728-x-90-pxEN_3ce6f379.gif",
|
type CacheRow = { rows: Ad[]; loadedAt: number };
|
||||||
alt: "LiveU 728x90",
|
let CACHE: CacheRow | null = null;
|
||||||
click_url: "https://bit.ly/4ghXVeq",
|
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/" },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
interface DbRow {
|
||||||
* 300x250 sidebar pool. ALL entries render on every page — SidebarAdStack
|
slug: string;
|
||||||
* stacks them vertically and shuffles the order per page-view so each banner
|
name: string;
|
||||||
* cycles through positions. Add new entries here to grow the stack.
|
image_path: string;
|
||||||
*
|
click_url: string | null;
|
||||||
* AJA, Zixi, Lectrosonics, Opengear, Studio Hero, Magewell were removed
|
size: "300x250" | "300x600" | "728x90";
|
||||||
* 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",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
async function loadFromDb(): Promise<Ad[]> {
|
||||||
* 300x600 fixed sidebar slot. Rendered at the TOP of SidebarAdStack and is
|
if (!SUPABASE_URL || !SUPABASE_ANON) return FALLBACK;
|
||||||
* NEVER rotated. Set to null to omit. Blackmagic ATEM creative pending.
|
try {
|
||||||
*/
|
const sb = createClient(SUPABASE_URL, SUPABASE_ANON, {
|
||||||
export const FIXED_300X600: Ad | null = null;
|
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[] {
|
export function shuffle<T>(arr: T[]): T[] {
|
||||||
const out = arr.slice();
|
const out = arr.slice();
|
||||||
for (let i = out.length - 1; i > 0; i--) {
|
for (let i = out.length - 1; i > 0; i--) {
|
||||||
@@ -67,31 +129,26 @@ export function shuffle<T>(arr: T[]): T[] {
|
|||||||
return out;
|
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[] {
|
export function rotateAll(size: Ad["size"], exclude: Set<string> = new Set()): Ad[] {
|
||||||
const pool =
|
const pool = readCachedAds().filter((a) => a.size === size);
|
||||||
size === "300x250" ? ADS_300X250
|
|
||||||
: size === "728x90" ? ADS_728X90
|
|
||||||
: size === "300x600" ? (FIXED_300X600 ? [FIXED_300X600] : [])
|
|
||||||
: [];
|
|
||||||
const key = (a: Ad) => a.src ?? a.label;
|
const key = (a: Ad) => a.src ?? a.label;
|
||||||
return shuffle(pool.filter((a) => !exclude.has(key(a))));
|
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 all = rotateAll(size, exclude);
|
const all = rotateAll(size, exclude);
|
||||||
return all.slice(0, Math.min(n, all.length));
|
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();
|
||||||
|
|||||||