From fc003fd285013c066f4088a3fa6442920357d624 Mon Sep 17 00:00:00 2001 From: Ryan Date: Sun, 10 May 2026 22:00:48 +0000 Subject: [PATCH] ads: add self-hosted click + impression tracking to AdImage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- src/components/AdImage.tsx | 64 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/src/components/AdImage.tsx b/src/components/AdImage.tsx index cd010d0..9c9f43a 100644 --- a/src/components/AdImage.tsx +++ b/src/components/AdImage.tsx @@ -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 = { @@ -7,6 +12,16 @@ const SIZE_DIM: Record = { "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(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 (