Skip to content

Commit c287b82

Browse files
ralyodioclaude
andcommitted
Serve a default house ad when no paid campaign is available
serveAd() returned null when a slot had no eligible/in-budget campaign, leaving publisher units blank. Now it falls back to a CrawlProof house ad so a slot is never empty: - lib/ads/house.ts: renders an image-based house ad from default AI-generated promo artwork (public/ads/house/*.webp) with an overlaid "Your ad here" pitch linking back to CrawlProof. Not metered or billed. - Wired into serveAd for both the no-creative and out-of-budget cases; invalid/ inactive slots and format mismatches still return null. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1e1a1da commit c287b82

4 files changed

Lines changed: 101 additions & 2 deletions

File tree

‎lib/ads/house.ts‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import crypto from "node:crypto";
2+
import { env } from "@/lib/env";
3+
import { formatSpec, type AdCreative, type AdFormatId } from "./creative";
4+
import type { Fill } from "./serve";
5+
6+
// Default "house" ad for the CrawlProof Ad Network. Shown when a slot has no
7+
// eligible paid campaign, so a publisher's unit is never blank. Uses default
8+
// AI-generated promo artwork (public/ads/house/*) with an overlaid pitch that
9+
// links back to CrawlProof. House ads are not metered or billed.
10+
11+
const HOUSE = {
12+
headline: "Your ad here",
13+
body: "Crypto pay-per-click ads for indie sites — no middlemen.",
14+
cta: "Advertise →",
15+
} as const;
16+
17+
function imageUrlFor(format: AdFormatId): string {
18+
const rect = format === "banner_300x250";
19+
return `${env.siteUrl}/ads/house/${rect ? "promo-rect" : "promo-wide"}.webp`;
20+
}
21+
22+
function esc(s: string): string {
23+
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
24+
}
25+
26+
export function renderHouseAdHtml(format: AdFormatId, clickUrl: string): string {
27+
const { w, h } = formatSpec(format);
28+
const img = imageUrlFor(format);
29+
const isMobile = format === "banner_320x50";
30+
const isRect = format === "banner_300x250";
31+
const row = !isRect; // leaderboard + mobile are horizontal
32+
33+
const label = `<span style="position:absolute;top:8px;left:10px;z-index:3;font-size:9px;letter-spacing:.14em;text-transform:uppercase;color:#9fb0c3">CrawlProof Ads</span>`;
34+
const headline = `<div style="font-weight:800;font-size:${isMobile ? 13 : isRect ? 20 : 17}px;line-height:1.1;color:#eef3f8">${esc(HOUSE.headline)}</div>`;
35+
const body = isMobile
36+
? ""
37+
: `<div style="font-size:${isRect ? 13 : 12}px;color:#c7d2de;margin-top:4px;max-width:${isRect ? "100%" : "62%"}">${esc(HOUSE.body)}</div>`;
38+
const cta = `<span style="background:#6ee7b7;color:#04121a;font-weight:700;border-radius:6px;padding:${isMobile ? "4px 8px" : "7px 12px"};font-size:${isMobile ? 11 : 13}px;white-space:nowrap">${esc(HOUSE.cta)}</span>`;
39+
40+
const content = row
41+
? `<div style="position:relative;z-index:2;display:flex;align-items:center;gap:10px;height:100%;padding:0 12px">
42+
<div style="min-width:0">${headline}${body}</div>
43+
<div style="margin-left:auto;flex:0 0 auto">${cta}</div>
44+
</div>`
45+
: `<div style="position:relative;z-index:2;display:flex;flex-direction:column;height:100%;padding:14px">
46+
<div style="margin-top:auto">${headline}${body}</div>
47+
<div style="margin-top:12px">${cta}</div>
48+
</div>`;
49+
50+
const overlay = isRect
51+
? "linear-gradient(180deg, rgba(7,10,16,.12) 0%, rgba(7,10,16,.86) 76%)"
52+
: "linear-gradient(90deg, rgba(7,10,16,.92) 0%, rgba(7,10,16,.5) 62%, rgba(7,10,16,.2) 100%)";
53+
54+
return `<!doctype html><html><head><meta charset="utf-8"><style>
55+
*{box-sizing:border-box;margin:0}
56+
a{text-decoration:none;display:block}
57+
.cp-ad{position:relative;width:${w}px;height:${h}px;overflow:hidden;border-radius:8px;
58+
border:1px solid rgba(255,255,255,.08);background:#070a10;
59+
font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
60+
.cp-ad .bg{position:absolute;inset:0;z-index:0;background:url("${esc(img)}") center/cover no-repeat}
61+
.cp-ad .ov{position:absolute;inset:0;z-index:1;background:${overlay}}
62+
</style></head><body>
63+
<a class="cp-ad" href="${esc(clickUrl)}" target="_blank" rel="noopener sponsored">
64+
<div class="bg"></div><div class="ov"></div>${label}${content}
65+
</a>
66+
</body></html>`;
67+
}
68+
69+
function houseCreative(format: AdFormatId): AdCreative {
70+
return {
71+
format,
72+
headline: HOUSE.headline,
73+
body: HOUSE.body,
74+
ctaText: HOUSE.cta,
75+
bgColor: "#070a10",
76+
fgColor: "#eef3f8",
77+
accentColor: "#6ee7b7",
78+
fontFamily: "system-ui, -apple-system, Segoe UI, Roboto, sans-serif",
79+
logoUrl: null,
80+
imageUrl: imageUrlFor(format),
81+
};
82+
}
83+
84+
/** A default house-ad fill promoting the CrawlProof Ad Network. Not metered. */
85+
export function houseFill(format: AdFormatId): Fill {
86+
const clickUrl = `${env.siteUrl}/?utm_source=house-ad&utm_medium=ad&utm_campaign=crawlproof-ads`;
87+
return {
88+
impressionId: crypto.randomUUID(),
89+
campaignId: "house",
90+
creativeId: "house",
91+
refSlug: "house",
92+
creative: houseCreative(format),
93+
clickUrl,
94+
html: renderHouseAdHtml(format, clickUrl),
95+
};
96+
}

‎lib/ads/serve.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
type AdCreative,
99
type AdFormatId,
1010
} from "./creative";
11+
import { houseFill } from "./house";
1112
import { CPC_CENTS, CPC_CREDITS, PLATFORM_RATE } from "./pricing";
1213

1314
// Server-side ad selection + metering. Runs under the service-role client so
@@ -101,7 +102,8 @@ export async function serveAd(
101102
.eq("ad_campaigns.status", "active")
102103
.limit(100);
103104

104-
if (!creatives || creatives.length === 0) return null;
105+
// No paid creative for this format → default CrawlProof house ad.
106+
if (!creatives || creatives.length === 0) return houseFill(format);
105107

106108
type CampaignJoin = {
107109
id: string;
@@ -125,7 +127,8 @@ export async function serveAd(
125127
return spentToday + CPC_CENTS <= c.daily_budget_cents;
126128
},
127129
);
128-
if (eligible.length === 0) return null;
130+
// Every candidate is out of budget → fall back to the house ad.
131+
if (eligible.length === 0) return houseFill(format);
129132

130133
const pick = eligible[Math.floor(Math.random() * eligible.length)];
131134
const campaign = oneCampaign(pick.ad_campaigns);

‎public/ads/house/promo-rect.webp‎

7.61 KB
Loading

‎public/ads/house/promo-wide.webp‎

9.84 KB
Loading

0 commit comments

Comments
 (0)