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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user