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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user