ad analytics: impressions + clicks + admin stats + /r/[slug] redirect

Tables (Phase B+):
  bb.ad_impressions   — every render = a row. is_bot + viewport flags
                        for filtering. No unique constraints — gross
                        counts per Ry an spec.
  bb.ad_clicks        — every /r/[slug] hit = a row.
  bb.ad_campaign_clients — schema only; future client reporting.

FK target is bb.banner_creatives (the renderer table from 5/15 banner
refresh). bb.ad_campaigns is the AdOps billing schema and unrelated.

Routes:
  GET  /r/[slug]                   — log click, 302 to bb.banner_creatives.click_url
  POST /api/track/impression       — record impression (slug | campaign_id),
                                     used by client beacon

AdImage rewrite:
  - link href is /r/{slug} target=_blank rel=sponsored noopener noreferrer
  - sendBeacon on mount fires impression (viewport=false)
  - additional sendBeacon on IntersectionObserver ≥0.5 (viewport=true)
  - removed direct supabase.from('banner_analytics').insert path

Ad type carries slug now; FALLBACK list + DB mapper both populate it.

/admin/banners:
  per-row stats — 24h, 7d, lifetime impressions/clicks/CTR
  link to /admin/banners/[id]/analytics

/admin/banners/[id]/analytics (new):
  recharts line charts for impressions + clicks (30d), top page paths,
  top referrer domains, bot/human toggle.

Tower Products orphan image file removed; banner was not in any active
table or in code (already off rotation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude (Phase B)
2026-05-15 01:45:33 +00:00
parent 597eac431d
commit c0584092eb
10 changed files with 751 additions and 73 deletions

View File

@@ -22,6 +22,16 @@ interface BannerRow {
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();
@@ -36,6 +46,47 @@ async function requireAdmin() {
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");
@@ -48,6 +99,7 @@ export default async function AdminBannersPage() {
.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]">
@@ -55,8 +107,10 @@ export default async function AdminBannersPage() {
<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).
{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">
@@ -64,7 +118,7 @@ export default async function AdminBannersPage() {
</Link>
</header>
<BannerRows banners={banners} />
<BannerRows banners={banners} stats={stats} />
</main>
);
}