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

@@ -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>
);
}