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