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

@@ -2,6 +2,7 @@
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
interface BannerRow {
id: string;
@@ -18,12 +19,54 @@ interface BannerRow {
updated_at: string;
}
interface StatBucket {
impressions: number;
clicks: number;
}
interface BannerStats {
d1: StatBucket;
d7: StatBucket;
lifetime: StatBucket;
}
function fmtDate(iso: string | null): string {
if (!iso) return "";
return iso.slice(0, 10);
}
export default function BannerRows({ banners }: { banners: BannerRow[] }) {
function pct(n: number, d: number): string {
if (!d) return "—";
return ((n / d) * 100).toFixed(2) + "%";
}
function fmtNum(n: number): string {
return n.toLocaleString();
}
function StatBlock({ label, b }: { label: string; b: StatBucket }) {
return (
<div className="rounded border border-[#1f2937] bg-[#070a10] px-3 py-2 text-xs">
<div className="uppercase tracking-wider text-[#6b7280]">{label}</div>
<div className="mt-1 text-[#e5e7eb]">
<span className="font-semibold">{fmtNum(b.impressions)}</span>
<span className="text-[#6b7280]"> imp</span>
<span className="mx-1 text-[#374151]">·</span>
<span className="font-semibold">{fmtNum(b.clicks)}</span>
<span className="text-[#6b7280]"> clk</span>
<span className="mx-1 text-[#374151]">·</span>
<span className="text-[#60a5fa]">{pct(b.clicks, b.impressions)}</span>
</div>
</div>
);
}
export default function BannerRows({
banners,
stats,
}: {
banners: BannerRow[];
stats: Record<string, BannerStats>;
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [edits, setEdits] = useState<Record<string, Partial<BannerRow>>>({});
@@ -61,6 +104,12 @@ export default function BannerRows({ banners }: { banners: BannerRow[] }) {
startTransition(() => router.refresh());
}
const empty: BannerStats = {
d1: { impressions: 0, clicks: 0 },
d7: { impressions: 0, clicks: 0 },
lifetime: { impressions: 0, clicks: 0 },
};
return (
<div className="space-y-4">
{err && <div className="rounded border border-red-400 bg-red-900/30 px-4 py-3 text-sm text-red-200">{err}</div>}
@@ -68,6 +117,7 @@ export default function BannerRows({ banners }: { banners: BannerRow[] }) {
{banners.map((b) => {
const dirty = !!edits[b.id];
const current: BannerRow = { ...b, ...(edits[b.id] || {}) };
const s = stats[b.id] || empty;
return (
<div key={b.id} className="rounded border border-[#1f2937] bg-[#0b0f17] p-4">
<div className="flex gap-4">
@@ -135,6 +185,7 @@ export default function BannerRows({ banners }: { banners: BannerRow[] }) {
<option value="scheduled">scheduled</option>
<option value="paused">paused</option>
<option value="expired">expired</option>
<option value="inactive">inactive</option>
</select>
</label>
@@ -148,16 +199,30 @@ export default function BannerRows({ banners }: { banners: BannerRow[] }) {
</div>
</div>
<div className="mt-3 flex items-center gap-3">
<button
onClick={() => save(b.id)}
disabled={!dirty || pending}
className="rounded bg-[#1d4ed8] px-3 py-1.5 text-sm font-medium text-white hover:bg-[#2563eb] disabled:opacity-40 disabled:cursor-not-allowed"
<div className="mt-4 grid grid-cols-1 md:grid-cols-3 gap-3">
<StatBlock label="Last 24h" b={s.d1} />
<StatBlock label="Last 7 days" b={s.d7} />
<StatBlock label="Lifetime" b={s.lifetime} />
</div>
<div className="mt-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<button
onClick={() => save(b.id)}
disabled={!dirty || pending}
className="rounded bg-[#1d4ed8] px-3 py-1.5 text-sm font-medium text-white hover:bg-[#2563eb] disabled:opacity-40 disabled:cursor-not-allowed"
>
Save changes
</button>
{saved === b.id && <span className="text-xs text-emerald-400">Saved</span>}
{dirty && saved !== b.id && <span className="text-xs text-amber-400">Unsaved</span>}
</div>
<Link
href={`/admin/banners/${b.id}/analytics`}
className="text-sm text-[#60a5fa] hover:underline"
>
Save changes
</button>
{saved === b.id && <span className="text-xs text-emerald-400">Saved</span>}
{dirty && saved !== b.id && <span className="text-xs text-amber-400">Unsaved</span>}
View detailed analytics
</Link>
</div>
</div>
);

View File

@@ -0,0 +1,132 @@
"use client";
import { useState } from "react";
import {
LineChart,
Line,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
CartesianGrid,
Legend,
} from "recharts";
interface Series {
date: string;
count: number;
}
interface TopRow {
label: string;
count: number;
}
export default function AnalyticsCharts({
impSeries,
clkSeries,
impSeriesBot,
clkSeriesBot,
topPages,
topReferrers,
totals,
}: {
impSeries: Series[];
clkSeries: Series[];
impSeriesBot: Series[];
clkSeriesBot: Series[];
topPages: TopRow[];
topReferrers: TopRow[];
totals: { impHuman: number; impBot: number; clkHuman: number; clkBot: number };
}) {
const [includeBots, setIncludeBots] = useState(false);
const imp = includeBots ? impSeriesBot : impSeries;
const clk = includeBots ? clkSeriesBot : clkSeries;
const totImp = includeBots ? totals.impHuman + totals.impBot : totals.impHuman;
const totClk = includeBots ? totals.clkHuman + totals.clkBot : totals.clkHuman;
const ctr = totImp ? ((totClk / totImp) * 100).toFixed(2) + "%" : "—";
return (
<>
<section className="grid grid-cols-1 md:grid-cols-4 gap-3 mb-6">
<Tile label="Impressions (30d)" value={totImp.toLocaleString()} />
<Tile label="Clicks (30d)" value={totClk.toLocaleString()} />
<Tile label="CTR (30d)" value={ctr} />
<Tile label="Bots" value={`${totals.impBot.toLocaleString()} imp · ${totals.clkBot.toLocaleString()} clk`} small />
</section>
<div className="mb-4 flex items-center gap-2 text-sm">
<input
id="bots"
type="checkbox"
checked={includeBots}
onChange={(e) => setIncludeBots(e.target.checked)}
className="rounded"
/>
<label htmlFor="bots" className="text-[#9ca3af]">Include bots</label>
</div>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<ChartCard title="Impressions per day" data={imp} stroke="#60a5fa" />
<ChartCard title="Clicks per day" data={clk} stroke="#34d399" />
</section>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4">
<TopListCard title="Top pages" rows={topPages} />
<TopListCard title="Top referrer domains" rows={topReferrers} />
</section>
</>
);
}
function Tile({ label, value, small }: { label: string; value: string; small?: boolean }) {
return (
<div className="rounded border border-[#1f2937] bg-[#0b0f17] p-4">
<div className="text-xs uppercase tracking-wider text-[#6b7280] mb-2">{label}</div>
<div className={small ? "text-base font-semibold text-[#e5e7eb]" : "text-2xl font-bold text-[#e5e7eb]"}>{value}</div>
</div>
);
}
function ChartCard({ title, data, stroke }: { title: string; data: Series[]; stroke: string }) {
return (
<div className="rounded border border-[#1f2937] bg-[#0b0f17] p-4">
<h2 className="text-sm font-semibold uppercase tracking-wider text-[#9ca3af] mb-3">{title}</h2>
<div style={{ width: "100%", height: 220 }}>
<ResponsiveContainer>
<LineChart data={data} margin={{ top: 5, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid stroke="#1f2937" strokeDasharray="2 4" />
<XAxis dataKey="date" tick={{ fontSize: 10, fill: "#6b7280" }} tickFormatter={(d: string) => d.slice(5)} />
<YAxis tick={{ fontSize: 10, fill: "#6b7280" }} allowDecimals={false} />
<Tooltip contentStyle={{ background: "#070a10", border: "1px solid #1f2937", color: "#e5e7eb" }} />
<Line type="monotone" dataKey="count" stroke={stroke} strokeWidth={2} dot={false} />
<Legend wrapperStyle={{ fontSize: 11, color: "#9ca3af" }} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
);
}
function TopListCard({ title, rows }: { title: string; rows: TopRow[] }) {
return (
<div className="rounded border border-[#1f2937] bg-[#0b0f17] p-4">
<h2 className="text-sm font-semibold uppercase tracking-wider text-[#9ca3af] mb-3">{title}</h2>
{rows.length === 0 ? (
<p className="text-sm text-[#6b7280]">No data yet.</p>
) : (
<table className="w-full text-sm">
<tbody>
{rows.map((r) => (
<tr key={r.label} className="border-b border-[#1f2937] last:border-0">
<td className="py-2 pr-3 text-[#e5e7eb] truncate max-w-[400px]" title={r.label}>{r.label}</td>
<td className="py-2 text-right text-[#60a5fa]">{r.count.toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}

View File

@@ -0,0 +1,187 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { createAdminClient } from "@/lib/supabase/admin";
import { createClient } from "@/lib/supabase/server";
import AnalyticsCharts from "./AnalyticsCharts";
export const dynamic = "force-dynamic";
export const revalidate = 0;
interface BannerRow {
id: string;
slug: string;
name: string;
image_path: string;
click_url: string | null;
size: string;
status: string;
}
interface Row {
campaign_id: string;
occurred_at: string;
page_path: string | null;
referrer: string | null;
is_bot: boolean;
viewport?: boolean;
}
async function requireAdmin() {
const sb = await createClient();
const { data: { user } } = await sb.auth.getUser();
if (!user) return { ok: false as const };
const { data: profile } = await sb
.from("user_profiles")
.select("role,is_active")
.eq("id", user.id)
.maybeSingle();
if (!profile?.is_active) return { ok: false as const };
if (!["administrator", "admin"].includes((profile as any).role)) return { ok: false as const };
return { ok: true as const };
}
function dayBuckets(rows: Row[], days = 30): { date: string; count: number }[] {
const today = new Date();
today.setUTCHours(0, 0, 0, 0);
const out: { date: string; count: number }[] = [];
const map = new Map<string, number>();
for (const r of rows) {
const d = r.occurred_at.slice(0, 10);
map.set(d, (map.get(d) || 0) + 1);
}
for (let i = days - 1; i >= 0; i--) {
const d = new Date(today.getTime() - i * 86400000);
const key = d.toISOString().slice(0, 10);
out.push({ date: key, count: map.get(key) || 0 });
}
return out;
}
function topGroup(rows: Row[], key: keyof Row, limit = 8): { label: string; count: number }[] {
const m = new Map<string, number>();
for (const r of rows) {
const v = (r[key] || "(none)") as string;
m.set(v, (m.get(v) || 0) + 1);
}
return [...m.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, limit)
.map(([label, count]) => ({ label, count }));
}
function referrerDomain(r: string | null): string {
if (!r) return "(direct)";
try {
const u = new URL(r);
return u.hostname || "(direct)";
} catch {
return "(invalid)";
}
}
export default async function BannerAnalyticsPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const guard = await requireAdmin();
if (!guard.ok) redirect("/admin");
const { id } = await params;
const admin = createAdminClient();
const { data: banner } = await admin
.from("banner_creatives")
.select("id, slug, name, image_path, click_url, size, status")
.eq("id", id)
.maybeSingle();
if (!banner) notFound();
const b = banner as BannerRow;
const sinceIso = new Date(Date.now() - 30 * 86400000).toISOString();
const [imp, clk] = await Promise.all([
admin
.from("ad_impressions")
.select("campaign_id, occurred_at, page_path, referrer, is_bot, viewport")
.eq("campaign_id", id)
.gte("occurred_at", sinceIso)
.order("occurred_at", { ascending: false })
.limit(50000),
admin
.from("ad_clicks")
.select("campaign_id, occurred_at, page_url, referrer, is_bot")
.eq("campaign_id", id)
.gte("occurred_at", sinceIso)
.order("occurred_at", { ascending: false })
.limit(50000),
]);
const impRows = (imp.data || []) as Row[];
const clkRows = (clk.data || []).map((r: any) => ({
campaign_id: r.campaign_id,
occurred_at: r.occurred_at,
page_path: r.page_url || null,
referrer: r.referrer,
is_bot: r.is_bot,
})) as Row[];
const impHuman = impRows.filter((r) => !r.is_bot);
const clkHuman = clkRows.filter((r) => !r.is_bot);
const impSeries = dayBuckets(impHuman, 30);
const clkSeries = dayBuckets(clkHuman, 30);
const impSeriesBot = dayBuckets(impRows, 30);
const clkSeriesBot = dayBuckets(clkRows, 30);
const topPages = topGroup(impHuman, "page_path");
const topReferrers = topGroup(
impHuman.map((r) => ({ ...r, referrer: referrerDomain(r.referrer) })),
"referrer",
);
return (
<main className="mx-auto max-w-6xl px-6 py-10 text-[#e5e7eb]">
<Link href="/admin/banners" className="text-sm text-[#60a5fa] hover:underline">
Back to banners
</Link>
<header className="mt-4 mb-6 border-b border-[#222] pb-4">
<div className="flex items-center gap-3">
<img
src={b.image_path}
alt={b.name}
style={{ height: 60, width: "auto" }}
className="rounded border border-[#1f2937]"
/>
<div>
<h1 className="text-2xl font-bold tracking-tight">{b.name}</h1>
<p className="text-sm text-[#9ca3af]">
{b.size} · {b.status} ·{" "}
{b.click_url && (
<a className="text-[#60a5fa] hover:underline" href={b.click_url} target="_blank" rel="noopener noreferrer">
{b.click_url.slice(0, 80)}{b.click_url.length > 80 ? "…" : ""}
</a>
)}
</p>
</div>
</div>
</header>
<AnalyticsCharts
impSeries={impSeries}
clkSeries={clkSeries}
impSeriesBot={impSeriesBot}
clkSeriesBot={clkSeriesBot}
topPages={topPages}
topReferrers={topReferrers}
totals={{
impHuman: impHuman.length,
impBot: impRows.length - impHuman.length,
clkHuman: clkHuman.length,
clkBot: clkRows.length - clkHuman.length,
}}
/>
</main>
);
}

View File

@@ -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>
);
}