Files
avbeat-com/src/app/admin/banners/[id]/analytics/page.tsx
Ryan Salazar (via Claude) 52fe7bceb6 slice 4: accent palette refinement — blue → teal
Replace the four accent-family hex codes site-wide with the teal palette
(keeps the dark theme; only swaps the accent family, not the dark base):

  #3b82f6 (accent primary CTA)   → #5B7C8D  (teal)        [839×]
  #2563eb (accent-dark / hover)  → #4A6473  (darker teal) [44×]
  #60a5fa (info link, lighter)   → #8FB0C3  (mid teal)    [68×]
  #1e3a5f (accent-muted bg)      → #2F4F5F  (navy)        [44×]

tailwind.config.js tokens updated to match (accent/accent-dark/accent-muted).
Sweep also covers tailwind.css. 108 files touched. The dark base (#0d0d0d,
#111, #161616, #1a1a1a etc.) is intentionally untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 23:33:04 +00:00

188 lines
5.5 KiB
TypeScript

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-[#8FB0C3] 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-[#8FB0C3] 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>
);
}