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:
@@ -1,4 +1,9 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
import AppImage from "@/components/ui/AppImage";
|
import AppImage from "@/components/ui/AppImage";
|
||||||
|
import { createClient } from "@/lib/supabase/client";
|
||||||
import type { Ad } from "@/lib/ads";
|
import type { Ad } from "@/lib/ads";
|
||||||
|
|
||||||
const SIZE_DIM: Record<Ad["size"], { w: number; h: number }> = {
|
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 },
|
"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({
|
export default function AdImage({
|
||||||
ad,
|
ad,
|
||||||
className = "",
|
className = "",
|
||||||
@@ -17,11 +32,60 @@ export default function AdImage({
|
|||||||
priority?: boolean;
|
priority?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { w, h } = SIZE_DIM[ad.size];
|
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 (
|
return (
|
||||||
<a
|
<a
|
||||||
|
ref={anchorRef}
|
||||||
href={ad.click_url}
|
href={ad.click_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="sponsored noopener noreferrer"
|
rel="sponsored noopener noreferrer"
|
||||||
|
onClick={handleClick}
|
||||||
aria-label={`Advertisement — ${ad.label}`}
|
aria-label={`Advertisement — ${ad.label}`}
|
||||||
className={`block ad-creative ${className}`}
|
className={`block ad-creative ${className}`}
|
||||||
data-ad-size={ad.size}
|
data-ad-size={ad.size}
|
||||||
|
|||||||
Reference in New Issue
Block a user