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/lib/ad-tracking.ts
Normal file
71
src/lib/ad-tracking.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
export interface Ad {
|
||||
slug?: string;
|
||||
label: string;
|
||||
size: "300x250" | "300x600" | "728x90";
|
||||
src: string;
|
||||
@@ -29,27 +30,35 @@ let CACHE: CacheRow | null = null;
|
||||
let REFRESH_IN_FLIGHT: Promise<void> | null = null;
|
||||
|
||||
const FALLBACK: Ad[] = [
|
||||
{ label: "LiveU 728x90", size: "728x90", src: "/legacy/ads/728-x-90-pxEN.gif",
|
||||
alt: "LiveU 728x90", click_url: "https://bit.ly/4ghXVeq" },
|
||||
{ label: "Blackmagic DaVinci Resolve 20", size: "300x600",
|
||||
{ slug: "liveu-728x90", label: "LiveU 728x90", size: "728x90",
|
||||
src: "/legacy/ads/728-x-90-pxEN.gif",
|
||||
alt: "LiveU 728x90", click_url: "https://bit.ly/4ghXVeq" },
|
||||
{ slug: "blackmagic-davinci-resolve-300x600",
|
||||
label: "Blackmagic DaVinci Resolve 20", size: "300x600",
|
||||
src: "/legacy/ads/Davinci-Resolve-20_300x600.jpg",
|
||||
alt: "Blackmagic Design DaVinci Resolve 20",
|
||||
click_url: "http://bmd.link/mRNsKu" },
|
||||
{ label: "Studio Hero", size: "300x250", src: "/legacy/ads/Studio-Hero-for-Broadcast-Beat.png",
|
||||
{ slug: "studio-hero-300x250", label: "Studio Hero", size: "300x250",
|
||||
src: "/legacy/ads/Studio-Hero-for-Broadcast-Beat.png",
|
||||
alt: "Studio Suite — Studio Hero", click_url: "http://www.TheStudioHero.com" },
|
||||
{ label: "Magewell Pro-Convert", size: "300x250",
|
||||
{ slug: "magewell-pro-convert-300x250", label: "Magewell Pro-Convert", size: "300x250",
|
||||
src: "/legacy/ads/Magewell_Pro-Convert-IP-to-HDMI_Sports_NAB2026.gif",
|
||||
alt: "Magewell Pro-Convert IP to HDMI",
|
||||
click_url: "https://www.magewell.com" },
|
||||
{ label: "LiveU PAYG", size: "300x250", src: "/legacy/ads/PAYG-300x250-1.gif",
|
||||
{ slug: "liveu-payg-300x250", label: "LiveU PAYG", size: "300x250",
|
||||
src: "/legacy/ads/PAYG-300x250-1.gif",
|
||||
alt: "LiveU Pay-As-You-Go", click_url: "https://bit.ly/4ghXVeq" },
|
||||
{ label: "AJA ColorBox", size: "300x250", src: "/legacy/ads/aja_2025_colorbox_og_colorbox_300x250_en-2.gif",
|
||||
alt: "AJA ColorBox", click_url: "https://www.aja.com" },
|
||||
{ label: "Zixi", size: "300x250", src: "/legacy/ads/Zixi_Ads_300x250.png",
|
||||
alt: "Zixi", click_url: "https://zixi.com/" },
|
||||
{ label: "Telycam MixOne / ExploreXE", size: "300x250", src: "/legacy/ads/Telycam_BroadcastBeat_300x250_MixOne-ExploreXE_with_NAB2026_2f30157f.gif",
|
||||
{ slug: "aja-colorbox-300x250", label: "AJA ColorBox", size: "300x250",
|
||||
src: "/legacy/ads/aja_2025_colorbox_og_colorbox_300x250_en-2.gif",
|
||||
alt: "AJA ColorBox", click_url: "https://www.aja.com" },
|
||||
{ slug: "zixi-300x250", label: "Zixi", size: "300x250",
|
||||
src: "/legacy/ads/Zixi_Ads_300x250.png",
|
||||
alt: "Zixi", click_url: "https://zixi.com/" },
|
||||
{ slug: "telycam-300x250", label: "Telycam MixOne / ExploreXE", size: "300x250",
|
||||
src: "/legacy/ads/Telycam_BroadcastBeat_300x250_MixOne-ExploreXE_with_NAB2026_2f30157f.gif",
|
||||
alt: "Telycam MixOne / ExploreXE — NAB 2026", click_url: "https://telycam.com/" },
|
||||
{ label: "Lectrosonics", size: "300x250", src: "/legacy/ads/300x250-banner-Our-Story-film_resize.jpg",
|
||||
{ slug: "lectrosonics-300x250", label: "Lectrosonics", size: "300x250",
|
||||
src: "/legacy/ads/300x250-banner-Our-Story-film_resize.jpg",
|
||||
alt: "Lectrosonics Our Story", click_url: "https://www.lectrosonics.com/" },
|
||||
];
|
||||
|
||||
@@ -82,6 +91,7 @@ async function loadFromDb(): Promise<Ad[]> {
|
||||
return FALLBACK;
|
||||
}
|
||||
return (data as DbRow[]).map((r) => ({
|
||||
slug: r.slug,
|
||||
label: r.name,
|
||||
size: r.size,
|
||||
src: r.image_path,
|
||||
|
||||
Reference in New Issue
Block a user