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:
71
src/app/api/track/impression/route.ts
Normal file
71
src/app/api/track/impression/route.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createAdminClient } from "@/lib/supabase/admin";
|
||||
import { clientIp, isBot, recordImpression, sessionIdFromCookies } from "@/lib/ad-tracking";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
interface Payload {
|
||||
campaign_id?: string;
|
||||
slug?: string;
|
||||
page_url?: string;
|
||||
page_path?: string;
|
||||
viewport?: boolean;
|
||||
}
|
||||
|
||||
let slugCache: Map<string, string> = new Map();
|
||||
let slugCacheLoadedAt = 0;
|
||||
const SLUG_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
async function resolveCampaignId(body: Payload): Promise<string | null> {
|
||||
if (body.campaign_id && /^[0-9a-f-]{36}$/i.test(body.campaign_id)) return body.campaign_id;
|
||||
if (!body.slug) return null;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - slugCacheLoadedAt > SLUG_CACHE_TTL_MS) {
|
||||
try {
|
||||
const admin = createAdminClient();
|
||||
const { data } = await admin.from("banner_creatives").select("id, slug, status");
|
||||
const m = new Map<string, string>();
|
||||
for (const r of (data || []) as any[]) {
|
||||
if (r.status === "active") m.set(r.slug, r.id);
|
||||
}
|
||||
slugCache = m;
|
||||
slugCacheLoadedAt = now;
|
||||
} catch {
|
||||
// keep stale cache on failure
|
||||
}
|
||||
}
|
||||
return slugCache.get(body.slug) || null;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
let body: Payload;
|
||||
try {
|
||||
body = (await req.json()) as Payload;
|
||||
} catch {
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
|
||||
const campaignId = await resolveCampaignId(body);
|
||||
if (!campaignId) return new NextResponse(null, { status: 204 });
|
||||
|
||||
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"));
|
||||
|
||||
recordImpression({
|
||||
campaignId,
|
||||
pageUrl: body.page_url ?? referrer,
|
||||
pagePath: body.page_path ?? null,
|
||||
referrer,
|
||||
userAgent: ua,
|
||||
ipAddress: ip,
|
||||
isBot: isBot(ua),
|
||||
viewport: !!body.viewport,
|
||||
sessionId: sid,
|
||||
}).catch((err) => console.error("[impression] insert failed:", err?.message));
|
||||
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
Reference in New Issue
Block a user