ads: add self-hosted click + impression tracking to AdImage

Migration creates bb.banner_analytics (mirrors article_analytics: public
insert, admin read), applied separately to Supabase.

AdImage becomes a client component that:
- Fires one 'impression' event per render via IntersectionObserver at 50%
  visibility (avoids counting ads scrolled past at the fold).
- Fires a 'click' event on anchor click before the new tab opens.

Renders the same <a><AppImage/></a> as before — visually unchanged. Session
ID stored in sessionStorage as bb_session_id (random uuid, lazily created).
Both parents (SidebarAdStack, ArticleBody) were already 'use client', so no
boundary shifts. Tracking is best-effort and never blocks the user.
This commit is contained in:
Ryan
2026-05-10 22:00:48 +00:00
parent aec6d0c43d
commit fc003fd285

View File

@@ -1,4 +1,9 @@
"use client";
import { useEffect, useRef } from "react";
import { usePathname } from "next/navigation";
import AppImage from "@/components/ui/AppImage";
import { createClient } from "@/lib/supabase/client";
import type { Ad } from "@/lib/ads";
const SIZE_DIM: Record<Ad["size"], { w: number; h: number }> = {
@@ -7,6 +12,16 @@ const SIZE_DIM: Record<Ad["size"], { w: number; h: number }> = {
"728x90": { w: 728, h: 90 },
};
function getSessionId(): string {
if (typeof window === "undefined") return "";
let sid = sessionStorage.getItem("bb_session_id");
if (!sid) {
sid = crypto.randomUUID();
sessionStorage.setItem("bb_session_id", sid);
}
return sid;
}
export default function AdImage({
ad,
className = "",
@@ -17,11 +32,60 @@ export default function AdImage({
priority?: boolean;
}) {
const { w, h } = SIZE_DIM[ad.size];
const anchorRef = useRef<HTMLAnchorElement | null>(null);
const impressionFiredRef = useRef(false);
const pathname = usePathname();
useEffect(() => {
const el = anchorRef.current;
if (!el || impressionFiredRef.current) return;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting && !impressionFiredRef.current) {
impressionFiredRef.current = true;
observer.disconnect();
void recordEvent("impression");
break;
}
}
},
{ threshold: 0.5 }
);
observer.observe(el);
return () => observer.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ad.src]);
async function recordEvent(eventType: "impression" | "click") {
try {
const supabase = createClient();
await supabase.from("banner_analytics").insert({
ad_src: ad.src,
ad_label: ad.label,
ad_size: ad.size,
event_type: eventType,
page: pathname || (typeof window !== "undefined" ? window.location.pathname : null),
session_id: getSessionId(),
referrer: typeof document !== "undefined" ? document.referrer || null : null,
});
} catch {
/* tracking is best-effort; never block the user */
}
}
function handleClick() {
void recordEvent("click");
}
return (
<a
ref={anchorRef}
href={ad.click_url}
target="_blank"
rel="sponsored noopener noreferrer"
onClick={handleClick}
aria-label={`Advertisement — ${ad.label}`}
className={`block ad-creative ${className}`}
data-ad-size={ad.size}