diff --git a/lib/ads/house.ts b/lib/ads/house.ts
new file mode 100644
index 00000000..50472bf7
--- /dev/null
+++ b/lib/ads/house.ts
@@ -0,0 +1,96 @@
+import crypto from "node:crypto";
+import { env } from "@/lib/env";
+import { formatSpec, type AdCreative, type AdFormatId } from "./creative";
+import type { Fill } from "./serve";
+
+// Default "house" ad for the CrawlProof Ad Network. Shown when a slot has no
+// eligible paid campaign, so a publisher's unit is never blank. Uses default
+// AI-generated promo artwork (public/ads/house/*) with an overlaid pitch that
+// links back to CrawlProof. House ads are not metered or billed.
+
+const HOUSE = {
+ headline: "Your ad here",
+ body: "Crypto pay-per-click ads for indie sites — no middlemen.",
+ cta: "Advertise →",
+} as const;
+
+function imageUrlFor(format: AdFormatId): string {
+ const rect = format === "banner_300x250";
+ return `${env.siteUrl}/ads/house/${rect ? "promo-rect" : "promo-wide"}.webp`;
+}
+
+function esc(s: string): string {
+ return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
+}
+
+export function renderHouseAdHtml(format: AdFormatId, clickUrl: string): string {
+ const { w, h } = formatSpec(format);
+ const img = imageUrlFor(format);
+ const isMobile = format === "banner_320x50";
+ const isRect = format === "banner_300x250";
+ const row = !isRect; // leaderboard + mobile are horizontal
+
+ const label = `CrawlProof Ads`;
+ const headline = `
${esc(HOUSE.headline)}
`;
+ const body = isMobile
+ ? ""
+ : `${esc(HOUSE.body)}
`;
+ const cta = `${esc(HOUSE.cta)}`;
+
+ const content = row
+ ? `
+
${headline}${body}
+
${cta}
+
`
+ : `
+
${headline}${body}
+
${cta}
+
`;
+
+ const overlay = isRect
+ ? "linear-gradient(180deg, rgba(7,10,16,.12) 0%, rgba(7,10,16,.86) 76%)"
+ : "linear-gradient(90deg, rgba(7,10,16,.92) 0%, rgba(7,10,16,.5) 62%, rgba(7,10,16,.2) 100%)";
+
+ return `
+
+ ${label}${content}
+
+ `;
+}
+
+function houseCreative(format: AdFormatId): AdCreative {
+ return {
+ format,
+ headline: HOUSE.headline,
+ body: HOUSE.body,
+ ctaText: HOUSE.cta,
+ bgColor: "#070a10",
+ fgColor: "#eef3f8",
+ accentColor: "#6ee7b7",
+ fontFamily: "system-ui, -apple-system, Segoe UI, Roboto, sans-serif",
+ logoUrl: null,
+ imageUrl: imageUrlFor(format),
+ };
+}
+
+/** A default house-ad fill promoting the CrawlProof Ad Network. Not metered. */
+export function houseFill(format: AdFormatId): Fill {
+ const clickUrl = `${env.siteUrl}/?utm_source=house-ad&utm_medium=ad&utm_campaign=crawlproof-ads`;
+ return {
+ impressionId: crypto.randomUUID(),
+ campaignId: "house",
+ creativeId: "house",
+ refSlug: "house",
+ creative: houseCreative(format),
+ clickUrl,
+ html: renderHouseAdHtml(format, clickUrl),
+ };
+}
diff --git a/lib/ads/serve.ts b/lib/ads/serve.ts
index 1d85a0cc..ae3fd127 100644
--- a/lib/ads/serve.ts
+++ b/lib/ads/serve.ts
@@ -8,6 +8,7 @@ import {
type AdCreative,
type AdFormatId,
} from "./creative";
+import { houseFill } from "./house";
import { CPC_CENTS, CPC_CREDITS, PLATFORM_RATE } from "./pricing";
// Server-side ad selection + metering. Runs under the service-role client so
@@ -101,7 +102,8 @@ export async function serveAd(
.eq("ad_campaigns.status", "active")
.limit(100);
- if (!creatives || creatives.length === 0) return null;
+ // No paid creative for this format → default CrawlProof house ad.
+ if (!creatives || creatives.length === 0) return houseFill(format);
type CampaignJoin = {
id: string;
@@ -125,7 +127,8 @@ export async function serveAd(
return spentToday + CPC_CENTS <= c.daily_budget_cents;
},
);
- if (eligible.length === 0) return null;
+ // Every candidate is out of budget → fall back to the house ad.
+ if (eligible.length === 0) return houseFill(format);
const pick = eligible[Math.floor(Math.random() * eligible.length)];
const campaign = oneCampaign(pick.ad_campaigns);
diff --git a/public/ads/house/promo-rect.webp b/public/ads/house/promo-rect.webp
new file mode 100644
index 00000000..b874bbf8
Binary files /dev/null and b/public/ads/house/promo-rect.webp differ
diff --git a/public/ads/house/promo-wide.webp b/public/ads/house/promo-wide.webp
new file mode 100644
index 00000000..4f776d5c
Binary files /dev/null and b/public/ads/house/promo-wide.webp differ