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>
72 lines
2.1 KiB
TypeScript
72 lines
2.1 KiB
TypeScript
import { createAdminClient } from "@/lib/supabase/admin";
|
|
|
|
const BOT_REGEX = /bot|crawl|spider|googlebot|bingbot|slurp|baidu|yandex|duckduck|facebook|pinterest|whatsapp|twitter|skype|linkedin|preview|fetch|curl|wget|monitor|uptime/i;
|
|
|
|
export function isBot(userAgent: string | null | undefined): boolean {
|
|
if (!userAgent) return true;
|
|
return BOT_REGEX.test(userAgent);
|
|
}
|
|
|
|
export function clientIp(headers: Headers): string | null {
|
|
const xff = headers.get("x-forwarded-for") || "";
|
|
const first = xff.split(",")[0]?.trim();
|
|
if (first) return first;
|
|
return headers.get("x-real-ip") || null;
|
|
}
|
|
|
|
export function sessionIdFromCookies(cookieHeader: string | null): string | null {
|
|
if (!cookieHeader) return null;
|
|
const m = cookieHeader.match(/bb_sid=([^;]+)/);
|
|
return m ? decodeURIComponent(m[1]) : null;
|
|
}
|
|
|
|
export interface ImpressionInput {
|
|
campaignId: string;
|
|
pageUrl?: string | null;
|
|
pagePath?: string | null;
|
|
referrer?: string | null;
|
|
userAgent?: string | null;
|
|
ipAddress?: string | null;
|
|
isBot?: boolean;
|
|
viewport?: boolean;
|
|
sessionId?: string | null;
|
|
}
|
|
|
|
export interface ClickInput {
|
|
campaignId: string;
|
|
pageUrl?: string | null;
|
|
referrer?: string | null;
|
|
userAgent?: string | null;
|
|
ipAddress?: string | null;
|
|
isBot?: boolean;
|
|
sessionId?: string | null;
|
|
}
|
|
|
|
export async function recordImpression(i: ImpressionInput): Promise<void> {
|
|
const admin = createAdminClient();
|
|
await admin.from("ad_impressions").insert({
|
|
campaign_id: i.campaignId,
|
|
page_url: i.pageUrl ?? null,
|
|
page_path: i.pagePath ?? null,
|
|
referrer: i.referrer ?? null,
|
|
user_agent: i.userAgent ?? null,
|
|
ip_address: i.ipAddress ?? null,
|
|
is_bot: !!i.isBot,
|
|
viewport: !!i.viewport,
|
|
session_id: i.sessionId ?? null,
|
|
});
|
|
}
|
|
|
|
export async function recordClick(c: ClickInput): Promise<void> {
|
|
const admin = createAdminClient();
|
|
await admin.from("ad_clicks").insert({
|
|
campaign_id: c.campaignId,
|
|
page_url: c.pageUrl ?? null,
|
|
referrer: c.referrer ?? null,
|
|
user_agent: c.userAgent ?? null,
|
|
ip_address: c.ipAddress ?? null,
|
|
is_bot: !!c.isBot,
|
|
session_id: c.sessionId ?? null,
|
|
});
|
|
}
|