- New AvBeatLogo inline-SVG component (rounded-square emblem with forward-leaning AV monogram + horizontal beam detail) at src/components/AvBeatLogo.tsx, three variants (full/wordmark/icon). - Header.tsx now uses the responsive AV BEAT logo lockup in the top-left, links to /, full lockup on desktop (emblem+wordmark+tagline), emblem+wordmark on tablet, emblem-only on mobile. - Removed DualSpeedTicker (the 'RECENT FORUM POSTS' ticker strip); the news ticker / glow chrome does not match the new aesthetic and the forum row was the explicit removal target. - Tailwind theme + globals.css now expose the AV BEAT 2026 palette as semantic tokens: --brand-blue (#1D4ED8), --brand-blue-bright (#38BDF8), --brand-navy (#0F172A), --brand-bg (#F8FAFC), --brand-surface, --brand-border, --brand-text, --brand-text-muted. Legacy --color-* aliases re-point to the new tokens so any inline-styled component re-themes for free. - Site-wide hex sweep migrates 2,769 hardcoded color literals across 144 files from the old dark-broadcast palette to the new tokens (orange -> blue, dark-brown -> white surface / navy text, cream -> navy). - Layout body class flipped from 'bb-neon' to 'bg-brand-bg text-brand-text' so the dark glow chrome no longer leaks through the new light theme.
188 lines
5.5 KiB
TypeScript
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-[#1D4ED8] 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-[#1D4ED8] 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>
|
|
);
|
|
}
|