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

View File

@@ -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,