Files
avbeat-com/src/app/admin/banners/page.tsx

125 lines
3.9 KiB
TypeScript

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;
}
interface StatBucket {
impressions: number;
clicks: number;
}
interface BannerStats {
d1: StatBucket;
d7: StatBucket;
lifetime: StatBucket;
}
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 };
}
async function loadStats(): Promise<Record<string, BannerStats>> {
const admin = createAdminClient();
const now = new Date();
const d1Iso = new Date(now.getTime() - 24 * 3600 * 1000).toISOString();
const d7Iso = new Date(now.getTime() - 7 * 24 * 3600 * 1000).toISOString();
async function bucket(table: "ad_impressions" | "ad_clicks", since: string | null) {
let q = admin.from(table).select("campaign_id");
if (since) q = q.gte("occurred_at", since);
const { data } = await q.limit(100000);
const counts: Record<string, number> = {};
for (const r of (data || []) as any[]) {
counts[r.campaign_id] = (counts[r.campaign_id] || 0) + 1;
}
return counts;
}
const [imp1, imp7, impL, clk1, clk7, clkL] = await Promise.all([
bucket("ad_impressions", d1Iso),
bucket("ad_impressions", d7Iso),
bucket("ad_impressions", null),
bucket("ad_clicks", d1Iso),
bucket("ad_clicks", d7Iso),
bucket("ad_clicks", null),
]);
const ids = new Set<string>([
...Object.keys(imp1), ...Object.keys(imp7), ...Object.keys(impL),
...Object.keys(clk1), ...Object.keys(clk7), ...Object.keys(clkL),
]);
const out: Record<string, BannerStats> = {};
for (const id of ids) {
out[id] = {
d1: { impressions: imp1[id] || 0, clicks: clk1[id] || 0 },
d7: { impressions: imp7[id] || 0, clicks: clk7[id] || 0 },
lifetime: { impressions: impL[id] || 0, clicks: clkL[id] || 0 },
};
}
return out;
}
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[];
const stats = await loadStats();
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.
Stats roll forward in real time from bb.ad_impressions /
bb.ad_clicks. Renderer cache TTL is 5 min edits surface
after the next refresh.
</p>
</div>
<Link href="/admin" className="text-sm text-[#60a5fa] hover:underline">
Admin home
</Link>
</header>
<BannerRows banners={banners} stats={stats} />
</main>
);
}