Skip to content

Commit 782cca8

Browse files
ralyodioclaude
andauthored
Ads: autobid by default, paper-money auction, bid history (#253)
Every campaign on the network is self-deal, so serveAd demoted every fill to the free tier and the free tier weighted everything at 1: nobody bid, and the auction, pacing and charge path had never run end to end with real numbers. - `autobid` on ad_campaigns, default true for every row. A pure pacing controller (lib/ads/autobid.ts) recomputes each bid hourly from the worker: behind daily pace raises it, ahead lowers it, capped by what the daily budget covers, held when already well above the market. The bid field is gone from the create form; the edit form shows an Autobid switch and the current bid, with a typed bid only when the switch is off. - The free tier is a paper auction: the same bid-weighted lottery, weighted by the paper bid and gated by a paper daily budget. Every free-tier click records what it would have cost (ad_clicks.paper_cents, campaign paper counters) through a new ad_paper_charge RPC that refuses any billed click. No credit balance is ever debited; the paid tier is untouched. - ad_bids logs every decision with its signals. The campaign page charts bid against impressions, clicks and tracker-attributed visits, with the last decisions and their reasons underneath. GET /api/ads/v1/campaigns/[id] carries the same under stats.bid; PATCH takes `autobid`; a bare bid_credits turns autobid off. CLI: `crawlproof ads bid <ref> auto|<credits>`. Migration 20260913120000_ad_autobid.sql is applied to prod (ref ywcizjsgrcmhgyplldac) and every function was exercised in a rolled-back transaction. The worker seeds every campaign's first bid at boot. Claude-Session: https://claude.ai/code/session_01PDJmt1CPi1fAkpD4FXRSHu Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 88d978b commit 782cca8

19 files changed

Lines changed: 2097 additions & 78 deletions

File tree

‎app/(app)/dashboard/ads/[id]/edit/edit-form.tsx‎

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ type Campaign = {
1414
destinationUrl: string;
1515
dailyBudgetCents: number;
1616
bidCredits: number;
17+
/** The controller sets the bid (default). Off: the number below is kept. */
18+
autobid: boolean;
1719
status: string;
1820
};
1921

@@ -33,6 +35,7 @@ export function EditCampaignForm({
3335
const [url, setUrl] = useState(campaign.destinationUrl);
3436
const [budget, setBudget] = useState(campaign.dailyBudgetCents / 100);
3537
const [bid, setBid] = useState((campaign.bidCredits * 5) / 100); // credits → $
38+
const [autobid, setAutobid] = useState(campaign.autobid);
3639
const [creatives, setCreatives] = useState(initial);
3740
const [active, setActive] = useState<AdFormatId>(initial[0]?.format ?? "banner_300x250");
3841
// Which polarity the previews show and the colour pickers edit. Publishers
@@ -99,6 +102,8 @@ export function EditCampaignForm({
99102
name,
100103
destinationUrl: url,
101104
dailyBudgetCents: Math.round(budget * 100),
105+
autobid,
106+
// Only meaningful when autobid is off; the action ignores it otherwise.
102107
bidCredits: Math.max(1, Math.round((bid * 100) / 5)),
103108
});
104109
if (!s.ok) return setError(s.error);
@@ -140,17 +145,39 @@ export function EditCampaignForm({
140145
onChange={(e) => setBudget(Math.max(0, Number(e.target.value)))}
141146
/>
142147
</label>
143-
<label className="block">
144-
<span className="text-xs uppercase tracking-wider text-[var(--color-muted)]">Max bid / click ($)</span>
145-
<input
146-
className="input mt-1"
147-
type="number"
148-
min={0.05}
149-
step={0.05}
150-
value={bid}
151-
onChange={(e) => setBid(Math.max(0.05, Number(e.target.value)))}
152-
/>
153-
</label>
148+
<div className="block">
149+
<span className="text-xs uppercase tracking-wider text-[var(--color-muted)]">Bid / click</span>
150+
<label className="mt-2 flex items-start gap-2 text-sm">
151+
<input
152+
type="checkbox"
153+
className="mt-1"
154+
checked={autobid}
155+
onChange={(e) => setAutobid(e.target.checked)}
156+
/>
157+
<span>
158+
<span className="font-medium">Autobid</span>
159+
<span className="block text-xs text-[var(--color-muted)]">
160+
{autobid
161+
? `Currently ${campaign.bidCredits} credits ($${((campaign.bidCredits * 5) / 100).toFixed(2)}). Set every hour from your daily budget and delivery: raised when behind pace, lowered when ahead.`
162+
: "Off. The bid below is kept until you turn autobid back on."}
163+
</span>
164+
</span>
165+
</label>
166+
{!autobid && (
167+
<div className="mt-2 flex items-center gap-2">
168+
<span className="text-[var(--color-muted)]">$</span>
169+
<input
170+
className="input"
171+
type="number"
172+
min={0.05}
173+
step={0.05}
174+
value={bid}
175+
onChange={(e) => setBid(Math.max(0.05, Number(e.target.value)))}
176+
/>
177+
<span className="text-sm text-[var(--color-muted)]">/click</span>
178+
</div>
179+
)}
180+
</div>
154181
</div>
155182
</div>
156183

‎app/(app)/dashboard/ads/[id]/edit/page.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export default async function EditCampaignPage({
3737

3838
const { data: campaign } = await supabase
3939
.from("ad_campaigns")
40-
.select("id, name, destination_url, daily_budget_cents, bid_credits, status, ref_slug")
40+
.select("id, name, destination_url, daily_budget_cents, bid_credits, autobid, status, ref_slug")
4141
.eq("id", id)
4242
.eq("owner_id", user.id)
4343
.maybeSingle();
@@ -84,6 +84,7 @@ export default async function EditCampaignPage({
8484
destinationUrl: campaign.destination_url,
8585
dailyBudgetCents: campaign.daily_budget_cents,
8686
bidCredits: campaign.bid_credits ?? 4,
87+
autobid: campaign.autobid !== false,
8788
status: campaign.status,
8889
}}
8990
creatives={creatives}

‎app/(app)/dashboard/ads/[id]/page.tsx‎

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,14 @@ import { formatSpec, type AdCreative, type AdFormatId } from "@/lib/ads/formats"
55
import { AdPreview } from "@/components/ads/ad-preview";
66
import { CampaignActions, RegenerateButton } from "@/components/ads/campaign-actions";
77
import { CampaignTrend } from "@/components/ads/campaign-trend";
8+
import { BidHistory } from "@/components/ads/bid-history";
89
import { getCampaignDailySeries } from "@/lib/ads/series";
10+
import { getBidHistory } from "@/lib/ads/bids";
11+
import { paperSpendTodayCents } from "@/lib/ads/autobid";
12+
import { serviceClient } from "@/lib/supabase/service";
913
import { campaignDisplayStatus, spendTodayCents, utcToday } from "@/lib/ads/status";
1014
import { promoStateForCampaign } from "@/lib/ads/promos";
11-
import { TRENDING_CPC_CENTS } from "@/lib/ads/pricing";
15+
import { CREDIT_CENTS, DEFAULT_BID_CREDITS, TRENDING_CPC_CENTS } from "@/lib/ads/pricing";
1216

1317
export const metadata = { title: "Campaign" };
1418

@@ -86,6 +90,38 @@ export default async function CampaignDetailPage({
8690
// not been applied here yet). Both read as "no promo".
8791
const promo = await promoStateForCampaign(supabase, id);
8892

93+
// The bid, who sets it, and its paper ledger: their own read too, for the
94+
// same reason — the columns ride behind a hand-applied migration and a
95+
// missing one must not 404 the campaign. The history comes through the
96+
// service client because the tracker rollup it joins is not readable by a
97+
// session, and ownership was already settled by the select above.
98+
type BidRow = {
99+
autobid?: boolean | null;
100+
paper_spend_today_cents?: number | null;
101+
paper_spend_date?: string | null;
102+
paper_total_cents?: number | null;
103+
};
104+
let bidRow: BidRow | null = null;
105+
try {
106+
const { data } = await supabase
107+
.from("ad_campaigns")
108+
.select("autobid, paper_spend_today_cents, paper_spend_date, paper_total_cents")
109+
.eq("id", id)
110+
.maybeSingle();
111+
bidRow = (data as BidRow | null) ?? null;
112+
} catch {
113+
bidRow = null;
114+
}
115+
const autobid = bidRow?.autobid !== false;
116+
const bidCredits = campaign.bid_credits ?? DEFAULT_BID_CREDITS;
117+
const history = await getBidHistory(serviceClient(), {
118+
campaignId: id,
119+
refSlug: campaign.ref_slug,
120+
ownerId: user.id,
121+
currentBid: bidCredits,
122+
days: 30,
123+
});
124+
89125
const impressions = (stats?.impressions as number) ?? 0;
90126
const clicks = (stats?.clicks as number) ?? 0;
91127
const freeImpressions = (stats?.free_impressions as number) ?? 0;
@@ -200,10 +236,35 @@ export default async function CampaignDetailPage({
200236
</div>
201237
)}
202238

239+
{/* The bid and its paper ledger. Paper is what the free-tier clicks would
240+
have cost at the bid — real numbers, no money moved — so the auction
241+
and the pacing can be read and judged before anybody is billed. */}
242+
<div className="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-3">
243+
<Stat
244+
label={autobid ? "Bid (autobid)" : "Bid (set by hand)"}
245+
value={`${bidCredits} cr`}
246+
note={`$${((bidCredits * CREDIT_CENTS) / 100).toFixed(2)} per click`}
247+
/>
248+
<Stat
249+
label="Paper spend today"
250+
value={dollars(bidRow ? paperSpendTodayCents({ daily_budget_cents: campaign.daily_budget_cents, ...bidRow }, today) : 0)}
251+
note="what free-tier clicks would have cost"
252+
/>
253+
<Stat
254+
label="Paper spend total"
255+
value={dollars(Number(bidRow?.paper_total_cents ?? 0))}
256+
note="no credits were moved"
257+
/>
258+
</div>
259+
203260
<div className="mt-4">
204261
<CampaignTrend data={daily} />
205262
</div>
206263

264+
<div className="mt-4">
265+
<BidHistory data={history.days} events={history.events} autobid={autobid} failed={history.failed} />
266+
</div>
267+
207268
{creatives.length > 0 && (
208269
<div className="mt-6">
209270
<h2 className="mb-3 font-semibold">Creatives</h2>
@@ -223,11 +284,12 @@ export default async function CampaignDetailPage({
223284
);
224285
}
225286

226-
function Stat({ label, value }: { label: string; value: string }) {
287+
function Stat({ label, value, note }: { label: string; value: string; note?: string }) {
227288
return (
228289
<div className="card p-4">
229290
<div className="text-xs uppercase tracking-wider text-[var(--color-muted)]">{label}</div>
230291
<div className="mt-1 text-2xl font-bold">{value}</div>
292+
{note && <div className="mt-0.5 text-xs text-[var(--color-muted)]">{note}</div>}
231293
</div>
232294
);
233295
}

‎app/(app)/dashboard/ads/new/form.tsx‎

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ export function NewAdForm() {
1717
const router = useRouter();
1818
const [url, setUrl] = useState("");
1919
const [budget, setBudget] = useState(5); // dollars/day
20-
const [bid, setBid] = useState(0.2); // dollars/click (max bid)
20+
// No bid field: the bid is automatic (see lib/ads/autobid.ts). The budget is
21+
// the one number an advertiser sets; the edit page can switch to a typed bid.
2122
const [name, setName] = useState("");
2223
// Trending targeting, and with it the 90 days. Off by default: it changes
2324
// where the ads run, and that is the advertiser's decision to make.
@@ -110,8 +111,7 @@ export function NewAdForm() {
110111
name,
111112
url,
112113
dailyBudgetCents: Math.round(budget * 100),
113-
// $ per click → credits (5¢/credit). Higher bids win more auctions.
114-
bidCredits: Math.max(1, Math.round((bid * 100) / 5)),
114+
// No bid: autobid (the default) sets it from the budget within the hour.
115115
brand,
116116
creatives,
117117
trendingTopics: trending,
@@ -165,21 +165,15 @@ export function NewAdForm() {
165165
<span className="text-sm text-[var(--color-muted)]">/day</span>
166166
</div>
167167
</div>
168-
<div className="w-40">
168+
<div className="w-56">
169169
<label className="text-xs uppercase tracking-wider text-[var(--color-muted)]">
170-
Max bid / click
170+
Bid / click
171171
</label>
172-
<div className="mt-1 flex items-center gap-2">
173-
<span className="text-[var(--color-muted)]">$</span>
174-
<input
175-
className="input"
176-
type="number"
177-
min={0.05}
178-
step={0.05}
179-
value={bid}
180-
onChange={(e) => setBid(Math.max(0.05, Number(e.target.value)))}
181-
/>
182-
<span className="text-sm text-[var(--color-muted)]">/click</span>
172+
<div className="mt-1 text-sm">
173+
<span className="font-medium">Autobid</span>
174+
<span className="block text-xs text-[var(--color-muted)]">
175+
Set for you from the budget, hourly. Change it later on the campaign.
176+
</span>
183177
</div>
184178
</div>
185179
<button type="submit" className="btn btn-primary" disabled={generating}>

‎app/(app)/dashboard/ads/page.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,9 @@ export default async function AdsPage({
256256
label="Today"
257257
value={`${dollars(spendTodayCents(c, today))} / ${dollars(c.daily_budget_cents)}`}
258258
/>
259+
{/* The bid autobid is making for it right now; the campaign
260+
page has the history and the reasons. */}
261+
<MiniStat label="Bid" value={`${c.bid_credits ?? 4} cr`} />
259262
</div>
260263
{!display.serving && (
261264
<p className="mt-2 text-sm text-[var(--color-muted)]">{display.hint}</p>

‎app/(marketing)/ads/page.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,8 +112,8 @@ export default async function AdsMarketingPage() {
112112

113113
<div className="mt-6 grid gap-4 sm:grid-cols-2">
114114
<Card
115-
title="You are not bidding against a wall"
116-
body="Placement is a bid-weighted lottery, not winner-takes-all. A higher bid wins more often, but it never corners the inventory — every live campaign keeps serving, so a modest budget still gets delivery instead of silence."
115+
title="You set a budget. The bid sets itself."
116+
body="Placement is a bid-weighted lottery, not winner-takes-all, and the bid is automatic: a pacing controller raises it when a campaign is behind its daily budget and lowers it when ahead, capped by what the budget can cover. Every live campaign keeps serving, so a modest budget still gets delivery instead of silence, and the campaign page shows every bid it made beside the clicks it got."
117117
/>
118118
<Card
119119
title="Clicks, counted conservatively"

‎app/actions/ads.ts‎

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type { SiteBrand } from "@/lib/ads/brand";
1818
import { MIN_PAYOUT_CENTS, DEFAULT_BID_CREDITS } from "@/lib/ads/pricing";
1919
import { cleanTopics } from "@/lib/ads/trending";
2020
import { grantTrendingPromo } from "@/lib/ads/promos";
21+
import { recordManualBid } from "@/lib/ads/bids";
2122
import { createCryptoPayout } from "@/lib/coinpay";
2223

2324
const ASSET_BUCKET = "ad-assets";
@@ -148,6 +149,8 @@ export async function saveCampaign(input: {
148149
url: string;
149150
dailyBudgetCents: number;
150151
bidCredits?: number;
152+
/** Off means "keep the bid I typed". Defaults to on: the controller bids. */
153+
autobid?: boolean;
151154
brand?: SiteBrand | null;
152155
creatives: Partial<AdCreative>[];
153156
summary?: Partial<AdSummary> | null;
@@ -189,6 +192,9 @@ export async function saveCampaign(input: {
189192
brand: input.brand ?? {},
190193
};
191194
if (org.id) payload.organization_id = org.id;
195+
// Autobid is the column default; only an explicit "keep my bid" is written,
196+
// and it is dropped with the other optional columns on a schema-lag retry.
197+
if (input.autobid === false) payload.autobid = false;
192198
// Trending targeting, and the subjects it targets on. The subjects come from
193199
// the page's own words rather than anything typed here: a campaign claiming
194200
// topics its landing page never mentions is how contextual targeting gets
@@ -230,10 +236,11 @@ export async function saveCampaign(input: {
230236
// of the schema. Retry without them rather than refusing to create the
231237
// campaign: prose is an enhancement, a campaign that cannot be saved is the
232238
// whole product failing. Same trade the impression short_code makes.
233-
if (campaign.error && /summary_|trending_topics|topics|schema cache|column/i.test(campaign.error.message ?? "")) {
239+
if (campaign.error && /summary_|trending_topics|topics|autobid|schema cache|column/i.test(campaign.error.message ?? "")) {
234240
for (const key of Object.keys(summaryFields)) delete payload[key];
235241
delete payload.trending_topics;
236242
delete payload.topics;
243+
delete payload.autobid;
237244
campaign = await supabase
238245
.from("ad_campaigns")
239246
.insert(payload)
@@ -285,6 +292,8 @@ export async function updateCampaign(input: {
285292
name?: string;
286293
dailyBudgetCents?: number;
287294
bidCredits?: number;
295+
/** Hand the bid back to the controller (true) or keep the typed one (false). */
296+
autobid?: boolean;
288297
destinationUrl?: string;
289298
}): Promise<{ ok: true } | { ok: false; error: string }> {
290299
const supabase = await createClient();
@@ -300,9 +309,13 @@ export async function updateCampaign(input: {
300309
if (Number.isFinite(input.dailyBudgetCents)) {
301310
patch.daily_budget_cents = Math.max(0, Math.round(input.dailyBudgetCents!));
302311
}
303-
if (Number.isFinite(input.bidCredits)) {
312+
// A bid typed while autobid is on would be overwritten within the hour, so
313+
// it is only taken when the form is handing the bid to the person.
314+
const manualBid = input.autobid !== true && Number.isFinite(input.bidCredits);
315+
if (manualBid) {
304316
patch.bid_credits = Math.min(200, Math.max(1, Math.round(input.bidCredits!)));
305317
}
318+
if (typeof input.autobid === "boolean") patch.autobid = input.autobid;
306319
if (typeof input.destinationUrl === "string" && input.destinationUrl.trim()) {
307320
const check = isAllowedTargetUrl(input.destinationUrl);
308321
if (!check.ok) return { ok: false, error: check.reason };
@@ -311,12 +324,40 @@ export async function updateCampaign(input: {
311324
}
312325
if (Object.keys(patch).length === 0) return { ok: true };
313326

314-
const { error } = await supabase
327+
// The bid before the edit, so the history can say what it moved from.
328+
let prevBid: number | null = null;
329+
if (manualBid) {
330+
const { data: before } = await supabase
331+
.from("ad_campaigns")
332+
.select("bid_credits")
333+
.eq("id", input.id)
334+
.eq("owner_id", user.id)
335+
.maybeSingle();
336+
prevBid = (before?.bid_credits as number | null | undefined) ?? null;
337+
}
338+
339+
let { error } = await supabase
315340
.from("ad_campaigns")
316341
.update(patch)
317342
.eq("id", input.id)
318343
.eq("owner_id", user.id);
344+
// `autobid` rides behind a hand-applied migration; an edit to the name or
345+
// the budget must still land if this deploy is ahead of the schema.
346+
if (error && patch.autobid !== undefined && /autobid|schema cache|column/i.test(error.message ?? "")) {
347+
delete patch.autobid;
348+
if (Object.keys(patch).length === 0) return { ok: false, error: "Autobid is not available on this deployment yet." };
349+
({ error } = await supabase.from("ad_campaigns").update(patch).eq("id", input.id).eq("owner_id", user.id));
350+
}
319351
if (error) return { ok: false, error: error.message };
352+
if (manualBid && prevBid !== patch.bid_credits) {
353+
await recordManualBid(serviceClient(), {
354+
campaignId: input.id,
355+
ownerId: user.id,
356+
prevBidCredits: prevBid,
357+
bidCredits: patch.bid_credits as number,
358+
reason: "set in the dashboard",
359+
});
360+
}
320361
revalidatePath("/dashboard/ads");
321362
revalidatePath(`/dashboard/ads/${input.id}`);
322363
return { ok: true };

0 commit comments

Comments
 (0)