← 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
M .env.exampleM apps/web/src/app/layout.tsxA apps/web/src/components/AdSlot.tsx
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’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 →