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:
Binary file not shown.
|
Before Width: | Height: | Size: 87 KiB |
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
interface BannerRow {
|
interface BannerRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -18,12 +19,54 @@ interface BannerRow {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface StatBucket {
|
||||||
|
impressions: number;
|
||||||
|
clicks: number;
|
||||||
|
}
|
||||||
|
interface BannerStats {
|
||||||
|
d1: StatBucket;
|
||||||
|
d7: StatBucket;
|
||||||
|
lifetime: StatBucket;
|
||||||
|
}
|
||||||
|
|
||||||
function fmtDate(iso: string | null): string {
|
function fmtDate(iso: string | null): string {
|
||||||
if (!iso) return "";
|
if (!iso) return "";
|
||||||
return iso.slice(0, 10);
|
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 router = useRouter();
|
||||||
const [pending, startTransition] = useTransition();
|
const [pending, startTransition] = useTransition();
|
||||||
const [edits, setEdits] = useState<Record<string, Partial<BannerRow>>>({});
|
const [edits, setEdits] = useState<Record<string, Partial<BannerRow>>>({});
|
||||||
@@ -61,6 +104,12 @@ export default function BannerRows({ banners }: { banners: BannerRow[] }) {
|
|||||||
startTransition(() => router.refresh());
|
startTransition(() => router.refresh());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const empty: BannerStats = {
|
||||||
|
d1: { impressions: 0, clicks: 0 },
|
||||||
|
d7: { impressions: 0, clicks: 0 },
|
||||||
|
lifetime: { impressions: 0, clicks: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<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>}
|
{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) => {
|
{banners.map((b) => {
|
||||||
const dirty = !!edits[b.id];
|
const dirty = !!edits[b.id];
|
||||||
const current: BannerRow = { ...b, ...(edits[b.id] || {}) };
|
const current: BannerRow = { ...b, ...(edits[b.id] || {}) };
|
||||||
|
const s = stats[b.id] || empty;
|
||||||
return (
|
return (
|
||||||
<div key={b.id} className="rounded border border-[#1f2937] bg-[#0b0f17] p-4">
|
<div key={b.id} className="rounded border border-[#1f2937] bg-[#0b0f17] p-4">
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
@@ -135,6 +185,7 @@ export default function BannerRows({ banners }: { banners: BannerRow[] }) {
|
|||||||
<option value="scheduled">scheduled</option>
|
<option value="scheduled">scheduled</option>
|
||||||
<option value="paused">paused</option>
|
<option value="paused">paused</option>
|
||||||
<option value="expired">expired</option>
|
<option value="expired">expired</option>
|
||||||
|
<option value="inactive">inactive</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -148,16 +199,30 @@ export default function BannerRows({ banners }: { banners: BannerRow[] }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex items-center gap-3">
|
<div className="mt-4 grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
<button
|
<StatBlock label="Last 24h" b={s.d1} />
|
||||||
onClick={() => save(b.id)}
|
<StatBlock label="Last 7 days" b={s.d7} />
|
||||||
disabled={!dirty || pending}
|
<StatBlock label="Lifetime" b={s.lifetime} />
|
||||||
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>
|
||||||
|
|
||||||
|
<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
|
View detailed analytics →
|
||||||
</button>
|
</Link>
|
||||||
{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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
132
src/app/admin/banners/[id]/analytics/AnalyticsCharts.tsx
Normal file
132
src/app/admin/banners/[id]/analytics/AnalyticsCharts.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
187
src/app/admin/banners/[id]/analytics/page.tsx
Normal file
187
src/app/admin/banners/[id]/analytics/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,6 +22,16 @@ interface BannerRow {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface StatBucket {
|
||||||
|
impressions: number;
|
||||||
|
clicks: number;
|
||||||
|
}
|
||||||
|
interface BannerStats {
|
||||||
|
d1: StatBucket;
|
||||||
|
d7: StatBucket;
|
||||||
|
lifetime: StatBucket;
|
||||||
|
}
|
||||||
|
|
||||||
async function requireAdmin() {
|
async function requireAdmin() {
|
||||||
const sb = await createClient();
|
const sb = await createClient();
|
||||||
const { data: { user } } = await sb.auth.getUser();
|
const { data: { user } } = await sb.auth.getUser();
|
||||||
@@ -36,6 +46,47 @@ async function requireAdmin() {
|
|||||||
return { ok: true as const };
|
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() {
|
export default async function AdminBannersPage() {
|
||||||
const guard = await requireAdmin();
|
const guard = await requireAdmin();
|
||||||
if (!guard.ok) redirect("/admin");
|
if (!guard.ok) redirect("/admin");
|
||||||
@@ -48,6 +99,7 @@ export default async function AdminBannersPage() {
|
|||||||
.order("slug", { ascending: true });
|
.order("slug", { ascending: true });
|
||||||
|
|
||||||
const banners = (rows || []) as BannerRow[];
|
const banners = (rows || []) as BannerRow[];
|
||||||
|
const stats = await loadStats();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-6xl px-6 py-10 text-[#e5e7eb]">
|
<main className="mx-auto max-w-6xl px-6 py-10 text-[#e5e7eb]">
|
||||||
@@ -55,8 +107,10 @@ export default async function AdminBannersPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Banner creatives</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Banner creatives</h1>
|
||||||
<p className="text-sm text-[#9ca3af] mt-1">
|
<p className="text-sm text-[#9ca3af] mt-1">
|
||||||
{banners.length} banners · live data from bb.banner_creatives. Edits
|
{banners.length} banners · live data from bb.banner_creatives.
|
||||||
apply within 5 minutes (in-process cache TTL on the site renderer).
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/admin" className="text-sm text-[#60a5fa] hover:underline">
|
<Link href="/admin" className="text-sm text-[#60a5fa] hover:underline">
|
||||||
@@ -64,7 +118,7 @@ export default async function AdminBannersPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<BannerRows banners={banners} />
|
<BannerRows banners={banners} stats={stats} />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
71
src/app/api/track/impression/route.ts
Normal file
71
src/app/api/track/impression/route.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { createAdminClient } from "@/lib/supabase/admin";
|
||||||
|
import { clientIp, isBot, recordImpression, sessionIdFromCookies } from "@/lib/ad-tracking";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
interface Payload {
|
||||||
|
campaign_id?: string;
|
||||||
|
slug?: string;
|
||||||
|
page_url?: string;
|
||||||
|
page_path?: string;
|
||||||
|
viewport?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let slugCache: Map<string, string> = new Map();
|
||||||
|
let slugCacheLoadedAt = 0;
|
||||||
|
const SLUG_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
async function resolveCampaignId(body: Payload): Promise<string | null> {
|
||||||
|
if (body.campaign_id && /^[0-9a-f-]{36}$/i.test(body.campaign_id)) return body.campaign_id;
|
||||||
|
if (!body.slug) return null;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - slugCacheLoadedAt > SLUG_CACHE_TTL_MS) {
|
||||||
|
try {
|
||||||
|
const admin = createAdminClient();
|
||||||
|
const { data } = await admin.from("banner_creatives").select("id, slug, status");
|
||||||
|
const m = new Map<string, string>();
|
||||||
|
for (const r of (data || []) as any[]) {
|
||||||
|
if (r.status === "active") m.set(r.slug, r.id);
|
||||||
|
}
|
||||||
|
slugCache = m;
|
||||||
|
slugCacheLoadedAt = now;
|
||||||
|
} catch {
|
||||||
|
// keep stale cache on failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return slugCache.get(body.slug) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
let body: Payload;
|
||||||
|
try {
|
||||||
|
body = (await req.json()) as Payload;
|
||||||
|
} catch {
|
||||||
|
return new NextResponse(null, { status: 204 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const campaignId = await resolveCampaignId(body);
|
||||||
|
if (!campaignId) return new NextResponse(null, { status: 204 });
|
||||||
|
|
||||||
|
const ua = req.headers.get("user-agent");
|
||||||
|
const referrer = req.headers.get("referer");
|
||||||
|
const ip = clientIp(req.headers);
|
||||||
|
const sid = sessionIdFromCookies(req.headers.get("cookie"));
|
||||||
|
|
||||||
|
recordImpression({
|
||||||
|
campaignId,
|
||||||
|
pageUrl: body.page_url ?? referrer,
|
||||||
|
pagePath: body.page_path ?? null,
|
||||||
|
referrer,
|
||||||
|
userAgent: ua,
|
||||||
|
ipAddress: ip,
|
||||||
|
isBot: isBot(ua),
|
||||||
|
viewport: !!body.viewport,
|
||||||
|
sessionId: sid,
|
||||||
|
}).catch((err) => console.error("[impression] insert failed:", err?.message));
|
||||||
|
|
||||||
|
return new NextResponse(null, { status: 204 });
|
||||||
|
}
|
||||||
47
src/app/r/[slug]/route.ts
Normal file
47
src/app/r/[slug]/route.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { createAdminClient } from "@/lib/supabase/admin";
|
||||||
|
import { clientIp, isBot, recordClick, sessionIdFromCookies } from "@/lib/ad-tracking";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest, { params }: { params: Promise<{ slug: string }> }) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const admin = createAdminClient();
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
|
|
||||||
|
const { data, error } = await admin
|
||||||
|
.from("banner_creatives")
|
||||||
|
.select("id, click_url, status, start_date, end_date")
|
||||||
|
.eq("slug", slug)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (error || !data) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const row = data as any;
|
||||||
|
const active =
|
||||||
|
row.status === "active" &&
|
||||||
|
(!row.start_date || row.start_date <= nowIso) &&
|
||||||
|
(!row.end_date || row.end_date >= nowIso) &&
|
||||||
|
!!row.click_url;
|
||||||
|
if (!active) return NextResponse.json({ error: "Not active" }, { status: 410 });
|
||||||
|
|
||||||
|
const ua = req.headers.get("user-agent");
|
||||||
|
const referrer = req.headers.get("referer");
|
||||||
|
const ip = clientIp(req.headers);
|
||||||
|
const sid = sessionIdFromCookies(req.headers.get("cookie"));
|
||||||
|
|
||||||
|
// Fire-and-forget the INSERT — the click target is the most important
|
||||||
|
// thing on the response path. If the log write throws, swallow it.
|
||||||
|
recordClick({
|
||||||
|
campaignId: row.id,
|
||||||
|
pageUrl: referrer,
|
||||||
|
referrer,
|
||||||
|
userAgent: ua,
|
||||||
|
ipAddress: ip,
|
||||||
|
isBot: isBot(ua),
|
||||||
|
sessionId: sid,
|
||||||
|
}).catch((err) => console.error("[click] insert failed:", err?.message));
|
||||||
|
|
||||||
|
return NextResponse.redirect(row.click_url, 302);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
'use client';
|
"use client";
|
||||||
import { useEffect, useRef } from 'react';
|
|
||||||
import AppImage from '@/components/ui/AppImage';
|
import { useEffect, useRef } from "react";
|
||||||
import { createClient } from '@/lib/supabase/client';
|
import AppImage from "@/components/ui/AppImage";
|
||||||
|
|
||||||
interface Ad {
|
interface Ad {
|
||||||
|
slug?: string;
|
||||||
label: string;
|
label: string;
|
||||||
size: string;
|
size: string;
|
||||||
src?: string;
|
src?: string;
|
||||||
@@ -11,63 +12,103 @@ interface Ad {
|
|||||||
click_url?: string;
|
click_url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Renders a local creative from /public/legacy/ads/... and tracks impressions
|
// Banner renderer with impression + click tracking. Impressions are logged
|
||||||
// + clicks to bb.banner_analytics. The previous Adsanity iframe path
|
// twice per visible render via navigator.sendBeacon (no fetch promise
|
||||||
// (ads.broadcastbeat.com) was removed — that subdomain doesn't resolve and
|
// awaited, no impact on paint):
|
||||||
// caused 30s timeouts. To add a new banner, drop the file in
|
// 1. on mount — guarantees a count even if the user never sees the banner
|
||||||
// public/legacy/ads/ and add an entry to ADS_300X250 / ADS_728X90 in
|
// (gross-count policy, intentional)
|
||||||
// src/lib/ads.ts with src + alt + click_url.
|
// 2. on viewport intersection (≥500ms, threshold 0.5) — viewport=true,
|
||||||
export default function AdImage({ ad, page, priority }: { ad: Ad; page?: string; priority?: boolean }) {
|
// gives clients who care a "viewable" subset
|
||||||
|
//
|
||||||
|
// Clicks always route through /r/{slug} which logs server-side then 302s
|
||||||
|
// to the click_url stored in bb.banner_creatives. We do NOT also log on
|
||||||
|
// the link's onClick — the /r/ route is the single source of truth.
|
||||||
|
|
||||||
|
export default function AdImage({
|
||||||
|
ad,
|
||||||
|
page,
|
||||||
|
priority,
|
||||||
|
}: {
|
||||||
|
ad: Ad;
|
||||||
|
page?: string;
|
||||||
|
priority?: boolean;
|
||||||
|
}) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const trackedRef = useRef(false);
|
const beaconMount = useRef(false);
|
||||||
const supabase = createClient();
|
const beaconViewport = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ad.src) return;
|
if (!ad.src || !ad.slug) return;
|
||||||
const observer = new IntersectionObserver(([entry]) => {
|
|
||||||
if (entry.isIntersecting && !trackedRef.current) {
|
|
||||||
trackedRef.current = true;
|
|
||||||
supabase.from('banner_analytics').insert({
|
|
||||||
ad_src: ad.src,
|
|
||||||
ad_label: ad.label,
|
|
||||||
ad_size: ad.size,
|
|
||||||
event_type: 'impression',
|
|
||||||
page: page || (typeof window !== 'undefined' ? window.location.pathname : null),
|
|
||||||
session_id: typeof window !== 'undefined' ? (window as { __session_id?: string }).__session_id : null,
|
|
||||||
referrer: typeof document !== 'undefined' ? document.referrer : null,
|
|
||||||
}).then(() => undefined, (err: unknown) => console.error('Impression:', err));
|
|
||||||
}
|
|
||||||
}, { threshold: 0.5 });
|
|
||||||
if (containerRef.current) observer.observe(containerRef.current);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [ad, page, supabase]);
|
|
||||||
|
|
||||||
const handleClick = () => {
|
if (!beaconMount.current) {
|
||||||
supabase.from('banner_analytics').insert({
|
beaconMount.current = true;
|
||||||
ad_src: ad.src,
|
sendImpression(ad.slug, page, false);
|
||||||
ad_label: ad.label,
|
}
|
||||||
ad_size: ad.size,
|
|
||||||
event_type: 'click',
|
const el = containerRef.current;
|
||||||
page: page || (typeof window !== 'undefined' ? window.location.pathname : null),
|
if (!el || beaconViewport.current) return;
|
||||||
session_id: typeof window !== 'undefined' ? (window as { __session_id?: string }).__session_id : null,
|
const observer = new IntersectionObserver(
|
||||||
referrer: typeof document !== 'undefined' ? document.referrer : null,
|
(entries) => {
|
||||||
}).then(() => undefined, (err: unknown) => console.error('Click:', err));
|
for (const e of entries) {
|
||||||
};
|
if (e.isIntersecting && !beaconViewport.current) {
|
||||||
|
beaconViewport.current = true;
|
||||||
|
sendImpression(ad.slug!, page, true);
|
||||||
|
observer.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.5 },
|
||||||
|
);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [ad.src, ad.slug, page]);
|
||||||
|
|
||||||
if (!ad.src) return null;
|
if (!ad.src) return null;
|
||||||
|
|
||||||
const width = parseInt(ad.size.split('x')[0]);
|
const [w, h] = ad.size.split("x").map((n) => parseInt(n, 10));
|
||||||
const height = parseInt(ad.size.split('x')[1]);
|
const clickHref = ad.slug ? `/r/${ad.slug}` : ad.click_url || "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="ad-container">
|
<div ref={containerRef} className="ad-container">
|
||||||
{ad.click_url ? (
|
{clickHref ? (
|
||||||
<a href={ad.click_url} rel="sponsored noopener noreferrer" onClick={handleClick}>
|
<a href={clickHref} target="_blank" rel="sponsored noopener noreferrer">
|
||||||
<AppImage src={ad.src} alt={ad.alt || ad.label} width={width} height={height} priority={priority} />
|
<AppImage src={ad.src} alt={ad.alt || ad.label} width={w} height={h} priority={priority} />
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<AppImage src={ad.src} alt={ad.alt || ad.label} width={width} height={height} priority={priority} />
|
<AppImage src={ad.src} alt={ad.alt || ad.label} width={w} height={h} priority={priority} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sendImpression(slug: string, page: string | undefined, viewport: boolean) {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
// We need the campaign UUID, not the slug, on the wire — but the
|
||||||
|
// server-side /api/track/impression endpoint accepts slug-or-uuid and
|
||||||
|
// resolves it. To keep payload tiny and avoid leaking IDs, we send
|
||||||
|
// slug + the viewport flag.
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
slug,
|
||||||
|
page_path: page || window.location.pathname,
|
||||||
|
page_url: window.location.href,
|
||||||
|
viewport,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const ok =
|
||||||
|
typeof navigator.sendBeacon === "function" &&
|
||||||
|
navigator.sendBeacon(
|
||||||
|
"/api/track/impression",
|
||||||
|
new Blob([payload], { type: "application/json" }),
|
||||||
|
);
|
||||||
|
if (ok) return;
|
||||||
|
} catch {
|
||||||
|
// fall through to fetch
|
||||||
|
}
|
||||||
|
fetch("/api/track/impression", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: payload,
|
||||||
|
keepalive: true,
|
||||||
|
}).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|||||||
71
src/lib/ad-tracking.ts
Normal file
71
src/lib/ad-tracking.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { createAdminClient } from "@/lib/supabase/admin";
|
||||||
|
|
||||||
|
const BOT_REGEX = /bot|crawl|spider|googlebot|bingbot|slurp|baidu|yandex|duckduck|facebook|pinterest|whatsapp|twitter|skype|linkedin|preview|fetch|curl|wget|monitor|uptime/i;
|
||||||
|
|
||||||
|
export function isBot(userAgent: string | null | undefined): boolean {
|
||||||
|
if (!userAgent) return true;
|
||||||
|
return BOT_REGEX.test(userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clientIp(headers: Headers): string | null {
|
||||||
|
const xff = headers.get("x-forwarded-for") || "";
|
||||||
|
const first = xff.split(",")[0]?.trim();
|
||||||
|
if (first) return first;
|
||||||
|
return headers.get("x-real-ip") || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sessionIdFromCookies(cookieHeader: string | null): string | null {
|
||||||
|
if (!cookieHeader) return null;
|
||||||
|
const m = cookieHeader.match(/bb_sid=([^;]+)/);
|
||||||
|
return m ? decodeURIComponent(m[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImpressionInput {
|
||||||
|
campaignId: string;
|
||||||
|
pageUrl?: string | null;
|
||||||
|
pagePath?: string | null;
|
||||||
|
referrer?: string | null;
|
||||||
|
userAgent?: string | null;
|
||||||
|
ipAddress?: string | null;
|
||||||
|
isBot?: boolean;
|
||||||
|
viewport?: boolean;
|
||||||
|
sessionId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClickInput {
|
||||||
|
campaignId: string;
|
||||||
|
pageUrl?: string | null;
|
||||||
|
referrer?: string | null;
|
||||||
|
userAgent?: string | null;
|
||||||
|
ipAddress?: string | null;
|
||||||
|
isBot?: boolean;
|
||||||
|
sessionId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordImpression(i: ImpressionInput): Promise<void> {
|
||||||
|
const admin = createAdminClient();
|
||||||
|
await admin.from("ad_impressions").insert({
|
||||||
|
campaign_id: i.campaignId,
|
||||||
|
page_url: i.pageUrl ?? null,
|
||||||
|
page_path: i.pagePath ?? null,
|
||||||
|
referrer: i.referrer ?? null,
|
||||||
|
user_agent: i.userAgent ?? null,
|
||||||
|
ip_address: i.ipAddress ?? null,
|
||||||
|
is_bot: !!i.isBot,
|
||||||
|
viewport: !!i.viewport,
|
||||||
|
session_id: i.sessionId ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordClick(c: ClickInput): Promise<void> {
|
||||||
|
const admin = createAdminClient();
|
||||||
|
await admin.from("ad_clicks").insert({
|
||||||
|
campaign_id: c.campaignId,
|
||||||
|
page_url: c.pageUrl ?? null,
|
||||||
|
referrer: c.referrer ?? null,
|
||||||
|
user_agent: c.userAgent ?? null,
|
||||||
|
ip_address: c.ipAddress ?? null,
|
||||||
|
is_bot: !!c.isBot,
|
||||||
|
session_id: c.sessionId ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
import { createClient } from "@supabase/supabase-js";
|
import { createClient } from "@supabase/supabase-js";
|
||||||
|
|
||||||
export interface Ad {
|
export interface Ad {
|
||||||
|
slug?: string;
|
||||||
label: string;
|
label: string;
|
||||||
size: "300x250" | "300x600" | "728x90";
|
size: "300x250" | "300x600" | "728x90";
|
||||||
src: string;
|
src: string;
|
||||||
@@ -29,27 +30,35 @@ let CACHE: CacheRow | null = null;
|
|||||||
let REFRESH_IN_FLIGHT: Promise<void> | null = null;
|
let REFRESH_IN_FLIGHT: Promise<void> | null = null;
|
||||||
|
|
||||||
const FALLBACK: Ad[] = [
|
const FALLBACK: Ad[] = [
|
||||||
{ label: "LiveU 728x90", size: "728x90", src: "/legacy/ads/728-x-90-pxEN.gif",
|
{ slug: "liveu-728x90", label: "LiveU 728x90", size: "728x90",
|
||||||
alt: "LiveU 728x90", click_url: "https://bit.ly/4ghXVeq" },
|
src: "/legacy/ads/728-x-90-pxEN.gif",
|
||||||
{ label: "Blackmagic DaVinci Resolve 20", size: "300x600",
|
alt: "LiveU 728x90", click_url: "https://bit.ly/4ghXVeq" },
|
||||||
|
{ slug: "blackmagic-davinci-resolve-300x600",
|
||||||
|
label: "Blackmagic DaVinci Resolve 20", size: "300x600",
|
||||||
src: "/legacy/ads/Davinci-Resolve-20_300x600.jpg",
|
src: "/legacy/ads/Davinci-Resolve-20_300x600.jpg",
|
||||||
alt: "Blackmagic Design DaVinci Resolve 20",
|
alt: "Blackmagic Design DaVinci Resolve 20",
|
||||||
click_url: "http://bmd.link/mRNsKu" },
|
click_url: "http://bmd.link/mRNsKu" },
|
||||||
{ label: "Studio Hero", size: "300x250", src: "/legacy/ads/Studio-Hero-for-Broadcast-Beat.png",
|
{ slug: "studio-hero-300x250", label: "Studio Hero", size: "300x250",
|
||||||
|
src: "/legacy/ads/Studio-Hero-for-Broadcast-Beat.png",
|
||||||
alt: "Studio Suite — Studio Hero", click_url: "http://www.TheStudioHero.com" },
|
alt: "Studio Suite — Studio Hero", click_url: "http://www.TheStudioHero.com" },
|
||||||
{ label: "Magewell Pro-Convert", size: "300x250",
|
{ slug: "magewell-pro-convert-300x250", label: "Magewell Pro-Convert", size: "300x250",
|
||||||
src: "/legacy/ads/Magewell_Pro-Convert-IP-to-HDMI_Sports_NAB2026.gif",
|
src: "/legacy/ads/Magewell_Pro-Convert-IP-to-HDMI_Sports_NAB2026.gif",
|
||||||
alt: "Magewell Pro-Convert IP to HDMI",
|
alt: "Magewell Pro-Convert IP to HDMI",
|
||||||
click_url: "https://www.magewell.com" },
|
click_url: "https://www.magewell.com" },
|
||||||
{ label: "LiveU PAYG", size: "300x250", src: "/legacy/ads/PAYG-300x250-1.gif",
|
{ slug: "liveu-payg-300x250", label: "LiveU PAYG", size: "300x250",
|
||||||
|
src: "/legacy/ads/PAYG-300x250-1.gif",
|
||||||
alt: "LiveU Pay-As-You-Go", click_url: "https://bit.ly/4ghXVeq" },
|
alt: "LiveU Pay-As-You-Go", click_url: "https://bit.ly/4ghXVeq" },
|
||||||
{ label: "AJA ColorBox", size: "300x250", src: "/legacy/ads/aja_2025_colorbox_og_colorbox_300x250_en-2.gif",
|
{ slug: "aja-colorbox-300x250", label: "AJA ColorBox", size: "300x250",
|
||||||
alt: "AJA ColorBox", click_url: "https://www.aja.com" },
|
src: "/legacy/ads/aja_2025_colorbox_og_colorbox_300x250_en-2.gif",
|
||||||
{ label: "Zixi", size: "300x250", src: "/legacy/ads/Zixi_Ads_300x250.png",
|
alt: "AJA ColorBox", click_url: "https://www.aja.com" },
|
||||||
alt: "Zixi", click_url: "https://zixi.com/" },
|
{ slug: "zixi-300x250", label: "Zixi", size: "300x250",
|
||||||
{ label: "Telycam MixOne / ExploreXE", size: "300x250", src: "/legacy/ads/Telycam_BroadcastBeat_300x250_MixOne-ExploreXE_with_NAB2026_2f30157f.gif",
|
src: "/legacy/ads/Zixi_Ads_300x250.png",
|
||||||
|
alt: "Zixi", click_url: "https://zixi.com/" },
|
||||||
|
{ slug: "telycam-300x250", label: "Telycam MixOne / ExploreXE", size: "300x250",
|
||||||
|
src: "/legacy/ads/Telycam_BroadcastBeat_300x250_MixOne-ExploreXE_with_NAB2026_2f30157f.gif",
|
||||||
alt: "Telycam MixOne / ExploreXE — NAB 2026", click_url: "https://telycam.com/" },
|
alt: "Telycam MixOne / ExploreXE — NAB 2026", click_url: "https://telycam.com/" },
|
||||||
{ label: "Lectrosonics", size: "300x250", src: "/legacy/ads/300x250-banner-Our-Story-film_resize.jpg",
|
{ slug: "lectrosonics-300x250", label: "Lectrosonics", size: "300x250",
|
||||||
|
src: "/legacy/ads/300x250-banner-Our-Story-film_resize.jpg",
|
||||||
alt: "Lectrosonics Our Story", click_url: "https://www.lectrosonics.com/" },
|
alt: "Lectrosonics Our Story", click_url: "https://www.lectrosonics.com/" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -82,6 +91,7 @@ async function loadFromDb(): Promise<Ad[]> {
|
|||||||
return FALLBACK;
|
return FALLBACK;
|
||||||
}
|
}
|
||||||
return (data as DbRow[]).map((r) => ({
|
return (data as DbRow[]).map((r) => ({
|
||||||
|
slug: r.slug,
|
||||||
label: r.name,
|
label: r.name,
|
||||||
size: r.size,
|
size: r.size,
|
||||||
src: r.image_path,
|
src: r.image_path,
|
||||||
|
|||||||
Reference in New Issue
Block a user