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

47
src/app/r/[slug]/route.ts Normal file
View File

@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { createAdminClient } from "@/lib/supabase/admin";
import { clientIp, isBot, recordClick, sessionIdFromCookies } from "@/lib/ad-tracking";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest, { params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const admin = createAdminClient();
const nowIso = new Date().toISOString();
const { data, error } = await admin
.from("banner_creatives")
.select("id, click_url, status, start_date, end_date")
.eq("slug", slug)
.maybeSingle();
if (error || !data) return NextResponse.json({ error: "Not found" }, { status: 404 });
const row = data as any;
const active =
row.status === "active" &&
(!row.start_date || row.start_date <= nowIso) &&
(!row.end_date || row.end_date >= nowIso) &&
!!row.click_url;
if (!active) return NextResponse.json({ error: "Not active" }, { status: 410 });
const ua = req.headers.get("user-agent");
const referrer = req.headers.get("referer");
const ip = clientIp(req.headers);
const sid = sessionIdFromCookies(req.headers.get("cookie"));
// Fire-and-forget the INSERT — the click target is the most important
// thing on the response path. If the log write throws, swallow it.
recordClick({
campaignId: row.id,
pageUrl: referrer,
referrer,
userAgent: ua,
ipAddress: ip,
isBot: isBot(ua),
sessionId: sid,
}).catch((err) => console.error("[click] insert failed:", err?.message));
return NextResponse.redirect(row.click_url, 302);
}