Files
avbeat-com/src/app/admin/banners/[id]/analytics/AnalyticsCharts.tsx
Claude (Phase B) c0584092eb 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>
2026-05-15 01:45:33 +00:00

133 lines
4.5 KiB
TypeScript

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