From 0be93ac779333c89c165bb5cf2cb9688683d43e6 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 19 Oct 2026 07:49:06 +0000 Subject: [PATCH] feat(site): CrawlProof ad unit above the footer on public pages One unit, slot 5500e925, format picked from the viewport (728x90 desktop, 300x250 mobile) in a mount effect, then crawlproofAds.scan() with a bounded retry. Collapsed until filled; skipped on account, auth, checkout and /play. Co-Authored-By: Claude Opus 5.5 --- apps/site/app/_components/ad-unit.tsx | 83 +++++++++++++++++++++++++++ apps/site/app/globals.css | 28 +++++++++ apps/site/app/layout.tsx | 3 + 3 files changed, 114 insertions(+) create mode 100644 apps/site/app/_components/ad-unit.tsx diff --git a/apps/site/app/_components/ad-unit.tsx b/apps/site/app/_components/ad-unit.tsx new file mode 100644 index 0000000..2a8c290 --- /dev/null +++ b/apps/site/app/_components/ad-unit.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { usePathname } from "next/navigation"; + +/** + * One CrawlProof ad unit, above the footer on public pages. + * Manage the slot at https://crawlproof.com/ads/slots + */ +const AD_SLOT = "5500e925-8ab1-4dd9-b89d-eaafa3554f68"; + +/** Account, auth and checkout pages carry no ad. */ +const EXCLUDED_PREFIXES = [ + "/account", + "/checkout", + "/library", + "/orders", + "/login", + "/register", + "/forgot-password", + "/reset-password", + "/verify-email", + "/play", +]; + +/** 728x90 needs the full row; anything narrower gets the 300x250 rectangle. */ +const LEADERBOARD_QUERY = "(min-width: 768px)"; + +const SCAN_RETRY_MS = 200; +const SCAN_MAX_TRIES = 25; + +declare global { + interface Window { + crawlproofAds?: { scan: () => void }; + } +} + +function isExcluded(pathname: string): boolean { + return EXCLUDED_PREFIXES.some((p) => pathname === p || pathname.startsWith(`${p}/`)); +} + +export function AdUnit() { + const pathname = usePathname() ?? "/"; + if (isExcluded(pathname)) return null; + // Keyed by path so each client-side navigation gets a fresh unit: ad.js + // marks a filled element and never refills it in place. + return ; +} + +function AdSlot() { + const ref = useRef(null); + + useEffect(() => { + const el = ref.current; + if (!el) return; + // The server renders the element without data-slot, so ad.js's own + // DOMContentLoaded pass skips it; the format is chosen here from the real + // viewport and the unit fills exactly once. + el.dataset.format = window.matchMedia(LEADERBOARD_QUERY).matches + ? "banner_728x90" + : "banner_300x250"; + el.dataset.slot = AD_SLOT; + + // ad.js scans once at DOMContentLoaded and may not have loaded yet, so + // retry briefly; give up after ~5s so an ad blocker leaves no timer. + let tries = 0; + let timer: ReturnType | undefined; + const tick = () => { + if (window.crawlproofAds) { + window.crawlproofAds.scan(); + return; + } + if (++tries > SCAN_MAX_TRIES) return; + timer = setTimeout(tick, SCAN_RETRY_MS); + }; + tick(); + return () => { + if (timer) clearTimeout(timer); + }; + }, []); + + return
; +} diff --git a/apps/site/app/globals.css b/apps/site/app/globals.css index 6992e31..53b24ec 100644 --- a/apps/site/app/globals.css +++ b/apps/site/app/globals.css @@ -1759,3 +1759,31 @@ a { grid-template-columns: repeat(2, minmax(0, 1fr)); } } + +/* + * CrawlProof ad unit: zero height until ad.js starts a fill (it sets + * data-cp-filled, and removes it again on an empty response), so an unsold or + * blocked slot leaves no hole. The reserved height is the format plus the + * "Advertisement" caption ad.js draws above banners. + */ +.cp-ad { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + height: 0; + overflow: hidden; +} + +.cp-ad[data-cp-filled] { + height: auto; + margin: 2rem 0; +} + +.cp-ad[data-cp-filled][data-format="banner_728x90"] { + min-height: 106px; +} + +.cp-ad[data-cp-filled][data-format="banner_300x250"] { + min-height: 266px; +} diff --git a/apps/site/app/layout.tsx b/apps/site/app/layout.tsx index 912b083..527c3e3 100644 --- a/apps/site/app/layout.tsx +++ b/apps/site/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata, Viewport } from "next"; import "./globals.css"; import Script from "next/script"; import { Footer } from "@profullstack/footer/react"; +import { AdUnit } from "./_components/ad-unit"; import captures from "../public/media/yard/manifest.json"; import trailer from "../public/media/trailer/manifest.json"; @@ -125,6 +126,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
{children}
+