[object Object]

← back to Homesonspec

feat(web): gated Google AdSense banner slots (top + footer)

49800740e0ef46e6adb1cc32a0839e4e67f47c22 · 2026-08-02 08:07:41 -0700 · Steve Abrams

Reusable <AdSlot> + <AdSenseLoader>, driven by NEXT_PUBLIC_ADSENSE_CLIENT.
Renders nothing (no script, no markup, no network) until the ca-pub id is
set — one-switch-from-live. Banners live in labeled 'Advertisement' zones,
never interleaved into the listings grid (honors the no-mixed-placement
footer promise + AdSense content-separation policy). Steve-gated go-live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 49800740e0ef46e6adb1cc32a0839e4e67f47c22
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Aug 2 08:07:41 2026 -0700

    feat(web): gated Google AdSense banner slots (top + footer)
    
    Reusable <AdSlot> + <AdSenseLoader>, driven by NEXT_PUBLIC_ADSENSE_CLIENT.
    Renders nothing (no script, no markup, no network) until the ca-pub id is
    set — one-switch-from-live. Banners live in labeled 'Advertisement' zones,
    never interleaved into the listings grid (honors the no-mixed-placement
    footer promise + AdSense content-separation policy). Steve-gated go-live.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .env.example                       |  7 ++++
 apps/web/src/app/layout.tsx        |  8 ++++
 apps/web/src/components/AdSlot.tsx | 79 ++++++++++++++++++++++++++++++++++++++
 3 files changed, 94 insertions(+)

diff --git a/.env.example b/.env.example
index 69918d0c..ebddfb20 100644
--- a/.env.example
+++ b/.env.example
@@ -5,3 +5,10 @@ DATABASE_URL_TEST="postgresql://macstudio3@localhost/homesonspec_test?host=/tmp"
 BASIC_AUTH="admin:CHANGE_ME_BEFORE_DEPLOY"
 # Snapshot storage root (raw fetched bodies live on disk, not in PG)
 SNAPSHOT_DIR="./var/snapshots"
+
+# --- Google AdSense banners (GATED — ads OFF until these are set) ---
+# Supply the ca-pub-… publisher id ONLY after the AdSense account is approved.
+# Empty = no ad script, no ad markup, no network calls (site ships ad-free).
+NEXT_PUBLIC_ADSENSE_CLIENT=
+NEXT_PUBLIC_ADSENSE_SLOT_TOP=
+NEXT_PUBLIC_ADSENSE_SLOT_FOOTER=
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index 3eadff6a..044df920 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
 import Link from "next/link";
 import { Fraunces, Inter } from "next/font/google";
 import "./globals.css";
+import AdSlot, { AdSenseLoader } from "../components/AdSlot";
 
 // Display serif + body sans, exposed as CSS variables the @theme layer reads.
 const display = Fraunces({
@@ -22,6 +23,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
   return (
     <html lang="en" className={`${display.variable} ${sans.variable}`}>
       <body className="min-h-screen bg-neutral-50 font-sans text-neutral-900 antialiased">
+        <AdSenseLoader />
         {/* Live inventory only — every listing is real builder data with a verification label. */}
         <div className="bg-brand-950 px-4 py-1.5 text-center text-xs font-medium text-brand-100">
           Early preview — every listing is verified from the builder&rsquo;s own website and shows its
@@ -44,8 +46,14 @@ export default function RootLayout({ children }: { children: React.ReactNode })
           </nav>
         </header>
 
+        {/* Top leaderboard banner — gated; renders only when NEXT_PUBLIC_ADSENSE_CLIENT is set. */}
+        <AdSlot slot={process.env.NEXT_PUBLIC_ADSENSE_SLOT_TOP} className="mt-4 mb-2" />
+
         <main>{children}</main>
 
+        {/* Footer banner — separated from listings, honoring the no-mixed-placement promise. */}
+        <AdSlot slot={process.env.NEXT_PUBLIC_ADSENSE_SLOT_FOOTER} className="mt-12 mb-2" />
+
         <footer className="mt-16 border-t border-neutral-200 bg-white py-10 text-center text-xs text-neutral-500">
           <p className="font-display text-sm text-brand-800">
             Every new home. Every builder. One search.
diff --git a/apps/web/src/components/AdSlot.tsx b/apps/web/src/components/AdSlot.tsx
new file mode 100644
index 00000000..f251f8e1
--- /dev/null
+++ b/apps/web/src/components/AdSlot.tsx
@@ -0,0 +1,79 @@
+"use client";
+
+import Script from "next/script";
+import { useEffect } from "react";
+
+/**
+ * Google AdSense banner slot — GATED.
+ *
+ * Renders NOTHING until NEXT_PUBLIC_ADSENSE_CLIENT is set to a real
+ * `ca-pub-…` publisher id. No id → no script, no <ins>, no network call,
+ * so the site ships ad-free by default and Steve flips it live with one
+ * env var once the AdSense account is approved.
+ *
+ * Placement rule (honors the footer promise "Organic results are never
+ * mixed with paid placement"): banners live in their OWN labeled zones,
+ * never interleaved into the listings grid, always tagged "Advertisement".
+ */
+
+const CLIENT = process.env.NEXT_PUBLIC_ADSENSE_CLIENT; // unset = ads OFF
+
+declare global {
+  interface Window {
+    adsbygoogle?: unknown[];
+  }
+}
+
+/** Loads the AdSense library once, site-wide. No-op until the id is set. Mount in layout. */
+export function AdSenseLoader() {
+  if (!CLIENT) return null;
+  return (
+    <Script
+      id="adsbygoogle-init"
+      async
+      strategy="afterInteractive"
+      crossOrigin="anonymous"
+      src={`https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${CLIENT}`}
+    />
+  );
+}
+
+export default function AdSlot({
+  slot,
+  format = "auto",
+  className,
+}: {
+  slot?: string;
+  format?: string;
+  className?: string;
+}) {
+  useEffect(() => {
+    if (!CLIENT) return;
+    try {
+      (window.adsbygoogle = window.adsbygoogle || []).push({});
+    } catch {
+      /* AdSense not yet loaded — the loader retries the queue */
+    }
+  }, []);
+
+  if (!CLIENT) return null; // gated: nothing renders until the publisher id exists
+
+  return (
+    <aside
+      className={`mx-auto w-full max-w-7xl px-4 ${className ?? "my-6"}`}
+      aria-label="Advertisement"
+    >
+      <p className="mb-1 text-center text-[10px] uppercase tracking-wide text-neutral-400">
+        Advertisement
+      </p>
+      <ins
+        className="adsbygoogle block"
+        style={{ display: "block" }}
+        data-ad-client={CLIENT}
+        data-ad-slot={slot}
+        data-ad-format={format}
+        data-full-width-responsive="true"
+      />
+    </aside>
+  );
+}

← 84407145 fix: import-sweep PATH — add postgresql@14+homebrew bin so p  ·  back to Homesonspec  ·  docs(specs): all-specs capture audit — method + dr-horton re 478e1c73 →