diff --git a/public/legacy/ads/Broadcast-Beat-Ad-March-2023_2f1904b5.png b/public/legacy/ads/Broadcast-Beat-Ad-March-2023_2f1904b5.png deleted file mode 100644 index 5a7a9a3..0000000 Binary files a/public/legacy/ads/Broadcast-Beat-Ad-March-2023_2f1904b5.png and /dev/null differ diff --git a/src/app/admin/banners/BannerRows.tsx b/src/app/admin/banners/BannerRows.tsx index 0a32952..a12607f 100644 --- a/src/app/admin/banners/BannerRows.tsx +++ b/src/app/admin/banners/BannerRows.tsx @@ -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 ( +
+
{label}
+
+ {fmtNum(b.impressions)} + imp + · + {fmtNum(b.clicks)} + clk + · + {pct(b.clicks, b.impressions)} +
+
+ ); +} + +export default function BannerRows({ + banners, + stats, +}: { + banners: BannerRow[]; + stats: Record; +}) { const router = useRouter(); const [pending, startTransition] = useTransition(); const [edits, setEdits] = useState>>({}); @@ -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 (
{err &&
{err}
} @@ -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 (
@@ -135,6 +185,7 @@ export default function BannerRows({ banners }: { banners: BannerRow[] }) { + @@ -148,16 +199,30 @@ export default function BannerRows({ banners }: { banners: BannerRow[] }) {
-
- + {saved === b.id && Saved} + {dirty && saved !== b.id && Unsaved} +
+ - Save changes - - {saved === b.id && Saved} - {dirty && saved !== b.id && Unsaved} + View detailed analytics → +
); diff --git a/src/app/admin/banners/[id]/analytics/AnalyticsCharts.tsx b/src/app/admin/banners/[id]/analytics/AnalyticsCharts.tsx new file mode 100644 index 0000000..723ccab --- /dev/null +++ b/src/app/admin/banners/[id]/analytics/AnalyticsCharts.tsx @@ -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 ( + <> +
+ + + + +
+ +
+ setIncludeBots(e.target.checked)} + className="rounded" + /> + +
+ +
+ + +
+ +
+ + +
+ + ); +} + +function Tile({ label, value, small }: { label: string; value: string; small?: boolean }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function ChartCard({ title, data, stroke }: { title: string; data: Series[]; stroke: string }) { + return ( +
+

{title}

+
+ + + + d.slice(5)} /> + + + + + + +
+
+ ); +} + +function TopListCard({ title, rows }: { title: string; rows: TopRow[] }) { + return ( +
+

{title}

+ {rows.length === 0 ? ( +

No data yet.

+ ) : ( + + + {rows.map((r) => ( + + + + + ))} + +
{r.label}{r.count.toLocaleString()}
+ )} +
+ ); +} diff --git a/src/app/admin/banners/[id]/analytics/page.tsx b/src/app/admin/banners/[id]/analytics/page.tsx new file mode 100644 index 0000000..6b3ad6f --- /dev/null +++ b/src/app/admin/banners/[id]/analytics/page.tsx @@ -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(); + 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(); + 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 ( +
+ + ← Back to banners + + +
+
+ {b.name} +
+

{b.name}

+

+ {b.size} · {b.status} ·{" "} + {b.click_url && ( + + {b.click_url.slice(0, 80)}{b.click_url.length > 80 ? "…" : ""} + + )} +

+
+
+
+ + +
+ ); +} diff --git a/src/app/admin/banners/page.tsx b/src/app/admin/banners/page.tsx index b27eff4..7bbc719 100644 --- a/src/app/admin/banners/page.tsx +++ b/src/app/admin/banners/page.tsx @@ -22,6 +22,16 @@ interface BannerRow { updated_at: string; } +interface StatBucket { + impressions: number; + clicks: number; +} +interface BannerStats { + d1: StatBucket; + d7: StatBucket; + lifetime: StatBucket; +} + async function requireAdmin() { const sb = await createClient(); const { data: { user } } = await sb.auth.getUser(); @@ -36,6 +46,47 @@ async function requireAdmin() { return { ok: true as const }; } +async function loadStats(): Promise> { + 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 = {}; + 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([ + ...Object.keys(imp1), ...Object.keys(imp7), ...Object.keys(impL), + ...Object.keys(clk1), ...Object.keys(clk7), ...Object.keys(clkL), + ]); + const out: Record = {}; + 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() { const guard = await requireAdmin(); if (!guard.ok) redirect("/admin"); @@ -48,6 +99,7 @@ export default async function AdminBannersPage() { .order("slug", { ascending: true }); const banners = (rows || []) as BannerRow[]; + const stats = await loadStats(); return (
@@ -55,8 +107,10 @@ export default async function AdminBannersPage() {

Banner creatives

- {banners.length} banners · live data from bb.banner_creatives. Edits - apply within 5 minutes (in-process cache TTL on the site renderer). + {banners.length} banners · live data from bb.banner_creatives. + 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.

@@ -64,7 +118,7 @@ export default async function AdminBannersPage() { - +
); } diff --git a/src/app/api/track/impression/route.ts b/src/app/api/track/impression/route.ts new file mode 100644 index 0000000..8c0cbe8 --- /dev/null +++ b/src/app/api/track/impression/route.ts @@ -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 = new Map(); +let slugCacheLoadedAt = 0; +const SLUG_CACHE_TTL_MS = 5 * 60 * 1000; + +async function resolveCampaignId(body: Payload): Promise { + 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(); + 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 }); +} diff --git a/src/app/r/[slug]/route.ts b/src/app/r/[slug]/route.ts new file mode 100644 index 0000000..8539f73 --- /dev/null +++ b/src/app/r/[slug]/route.ts @@ -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); +} diff --git a/src/components/AdImage.tsx b/src/components/AdImage.tsx index 213de3c..ac79a96 100644 --- a/src/components/AdImage.tsx +++ b/src/components/AdImage.tsx @@ -1,9 +1,10 @@ -'use client'; -import { useEffect, useRef } from 'react'; -import AppImage from '@/components/ui/AppImage'; -import { createClient } from '@/lib/supabase/client'; +"use client"; + +import { useEffect, useRef } from "react"; +import AppImage from "@/components/ui/AppImage"; interface Ad { + slug?: string; label: string; size: string; src?: string; @@ -11,63 +12,103 @@ interface Ad { click_url?: string; } -// Renders a local creative from /public/legacy/ads/... and tracks impressions -// + clicks to bb.banner_analytics. The previous Adsanity iframe path -// (ads.broadcastbeat.com) was removed — that subdomain doesn't resolve and -// caused 30s timeouts. To add a new banner, drop the file in -// public/legacy/ads/ and add an entry to ADS_300X250 / ADS_728X90 in -// src/lib/ads.ts with src + alt + click_url. -export default function AdImage({ ad, page, priority }: { ad: Ad; page?: string; priority?: boolean }) { +// Banner renderer with impression + click tracking. Impressions are logged +// twice per visible render via navigator.sendBeacon (no fetch promise +// awaited, no impact on paint): +// 1. on mount — guarantees a count even if the user never sees the banner +// (gross-count policy, intentional) +// 2. on viewport intersection (≥500ms, threshold 0.5) — viewport=true, +// 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(null); - const trackedRef = useRef(false); - const supabase = createClient(); + const beaconMount = useRef(false); + const beaconViewport = useRef(false); useEffect(() => { - if (!ad.src) 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]); + if (!ad.src || !ad.slug) return; - const handleClick = () => { - supabase.from('banner_analytics').insert({ - ad_src: ad.src, - ad_label: ad.label, - ad_size: ad.size, - event_type: 'click', - 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('Click:', err)); - }; + if (!beaconMount.current) { + beaconMount.current = true; + sendImpression(ad.slug, page, false); + } + + const el = containerRef.current; + if (!el || beaconViewport.current) return; + const observer = new IntersectionObserver( + (entries) => { + 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; - const width = parseInt(ad.size.split('x')[0]); - const height = parseInt(ad.size.split('x')[1]); + const [w, h] = ad.size.split("x").map((n) => parseInt(n, 10)); + const clickHref = ad.slug ? `/r/${ad.slug}` : ad.click_url || ""; return (
- {ad.click_url ? ( - - + {clickHref ? ( + + ) : ( - + )}
); } + +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); +} diff --git a/src/lib/ad-tracking.ts b/src/lib/ad-tracking.ts new file mode 100644 index 0000000..485a30c --- /dev/null +++ b/src/lib/ad-tracking.ts @@ -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 { + 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 { + 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, + }); +} diff --git a/src/lib/ads.ts b/src/lib/ads.ts index dc7bc33..c636c26 100644 --- a/src/lib/ads.ts +++ b/src/lib/ads.ts @@ -12,6 +12,7 @@ import { createClient } from "@supabase/supabase-js"; export interface Ad { + slug?: string; label: string; size: "300x250" | "300x600" | "728x90"; src: string; @@ -29,27 +30,35 @@ let CACHE: CacheRow | null = null; let REFRESH_IN_FLIGHT: Promise | null = null; const FALLBACK: Ad[] = [ - { label: "LiveU 728x90", size: "728x90", src: "/legacy/ads/728-x-90-pxEN.gif", - alt: "LiveU 728x90", click_url: "https://bit.ly/4ghXVeq" }, - { label: "Blackmagic DaVinci Resolve 20", size: "300x600", + { slug: "liveu-728x90", label: "LiveU 728x90", size: "728x90", + src: "/legacy/ads/728-x-90-pxEN.gif", + 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", alt: "Blackmagic Design DaVinci Resolve 20", 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" }, - { 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", alt: "Magewell Pro-Convert IP to HDMI", 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" }, - { label: "AJA ColorBox", size: "300x250", src: "/legacy/ads/aja_2025_colorbox_og_colorbox_300x250_en-2.gif", - alt: "AJA ColorBox", click_url: "https://www.aja.com" }, - { label: "Zixi", size: "300x250", src: "/legacy/ads/Zixi_Ads_300x250.png", - alt: "Zixi", click_url: "https://zixi.com/" }, - { label: "Telycam MixOne / ExploreXE", size: "300x250", src: "/legacy/ads/Telycam_BroadcastBeat_300x250_MixOne-ExploreXE_with_NAB2026_2f30157f.gif", + { slug: "aja-colorbox-300x250", label: "AJA ColorBox", size: "300x250", + src: "/legacy/ads/aja_2025_colorbox_og_colorbox_300x250_en-2.gif", + alt: "AJA ColorBox", click_url: "https://www.aja.com" }, + { slug: "zixi-300x250", label: "Zixi", size: "300x250", + 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/" }, - { 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/" }, ]; @@ -82,6 +91,7 @@ async function loadFromDb(): Promise { return FALLBACK; } return (data as DbRow[]).map((r) => ({ + slug: r.slug, label: r.name, size: r.size, src: r.image_path,