Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 38 additions & 11 deletions app/(app)/dashboard/ads/[id]/edit/edit-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ type Campaign = {
destinationUrl: string;
dailyBudgetCents: number;
bidCredits: number;
/** The controller sets the bid (default). Off: the number below is kept. */
autobid: boolean;
status: string;
};

Expand All @@ -33,6 +35,7 @@ export function EditCampaignForm({
const [url, setUrl] = useState(campaign.destinationUrl);
const [budget, setBudget] = useState(campaign.dailyBudgetCents / 100);
const [bid, setBid] = useState((campaign.bidCredits * 5) / 100); // credits → $
const [autobid, setAutobid] = useState(campaign.autobid);
const [creatives, setCreatives] = useState(initial);
const [active, setActive] = useState<AdFormatId>(initial[0]?.format ?? "banner_300x250");
// Which polarity the previews show and the colour pickers edit. Publishers
Expand Down Expand Up @@ -99,6 +102,8 @@ export function EditCampaignForm({
name,
destinationUrl: url,
dailyBudgetCents: Math.round(budget * 100),
autobid,
// Only meaningful when autobid is off; the action ignores it otherwise.
bidCredits: Math.max(1, Math.round((bid * 100) / 5)),
});
if (!s.ok) return setError(s.error);
Expand Down Expand Up @@ -140,17 +145,39 @@ export function EditCampaignForm({
onChange={(e) => setBudget(Math.max(0, Number(e.target.value)))}
/>
</label>
<label className="block">
<span className="text-xs uppercase tracking-wider text-[var(--color-muted)]">Max bid / click ($)</span>
<input
className="input mt-1"
type="number"
min={0.05}
step={0.05}
value={bid}
onChange={(e) => setBid(Math.max(0.05, Number(e.target.value)))}
/>
</label>
<div className="block">
<span className="text-xs uppercase tracking-wider text-[var(--color-muted)]">Bid / click</span>
<label className="mt-2 flex items-start gap-2 text-sm">
<input
type="checkbox"
className="mt-1"
checked={autobid}
onChange={(e) => setAutobid(e.target.checked)}
/>
<span>
<span className="font-medium">Autobid</span>
<span className="block text-xs text-[var(--color-muted)]">
{autobid
? `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.`
: "Off. The bid below is kept until you turn autobid back on."}
</span>
</span>
</label>
{!autobid && (
<div className="mt-2 flex items-center gap-2">
<span className="text-[var(--color-muted)]">$</span>
<input
className="input"
type="number"
min={0.05}
step={0.05}
value={bid}
onChange={(e) => setBid(Math.max(0.05, Number(e.target.value)))}
/>
<span className="text-sm text-[var(--color-muted)]">/click</span>
</div>
)}
</div>
</div>
</div>

Expand Down
3 changes: 2 additions & 1 deletion app/(app)/dashboard/ads/[id]/edit/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export default async function EditCampaignPage({

const { data: campaign } = await supabase
.from("ad_campaigns")
.select("id, name, destination_url, daily_budget_cents, bid_credits, status, ref_slug")
.select("id, name, destination_url, daily_budget_cents, bid_credits, autobid, status, ref_slug")
.eq("id", id)
.eq("owner_id", user.id)
.maybeSingle();
Expand Down Expand Up @@ -84,6 +84,7 @@ export default async function EditCampaignPage({
destinationUrl: campaign.destination_url,
dailyBudgetCents: campaign.daily_budget_cents,
bidCredits: campaign.bid_credits ?? 4,
autobid: campaign.autobid !== false,
status: campaign.status,
}}
creatives={creatives}
Expand Down
66 changes: 64 additions & 2 deletions app/(app)/dashboard/ads/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,14 @@ import { formatSpec, type AdCreative, type AdFormatId } from "@/lib/ads/formats"
import { AdPreview } from "@/components/ads/ad-preview";
import { CampaignActions, RegenerateButton } from "@/components/ads/campaign-actions";
import { CampaignTrend } from "@/components/ads/campaign-trend";
import { BidHistory } from "@/components/ads/bid-history";
import { getCampaignDailySeries } from "@/lib/ads/series";
import { getBidHistory } from "@/lib/ads/bids";
import { paperSpendTodayCents } from "@/lib/ads/autobid";
import { serviceClient } from "@/lib/supabase/service";
import { campaignDisplayStatus, spendTodayCents, utcToday } from "@/lib/ads/status";
import { promoStateForCampaign } from "@/lib/ads/promos";
import { TRENDING_CPC_CENTS } from "@/lib/ads/pricing";
import { CREDIT_CENTS, DEFAULT_BID_CREDITS, TRENDING_CPC_CENTS } from "@/lib/ads/pricing";

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

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

// The bid, who sets it, and its paper ledger: their own read too, for the
// same reason — the columns ride behind a hand-applied migration and a
// missing one must not 404 the campaign. The history comes through the
// service client because the tracker rollup it joins is not readable by a
// session, and ownership was already settled by the select above.
type BidRow = {
autobid?: boolean | null;
paper_spend_today_cents?: number | null;
paper_spend_date?: string | null;
paper_total_cents?: number | null;
};
let bidRow: BidRow | null = null;
try {
const { data } = await supabase
.from("ad_campaigns")
.select("autobid, paper_spend_today_cents, paper_spend_date, paper_total_cents")
.eq("id", id)
.maybeSingle();
bidRow = (data as BidRow | null) ?? null;
} catch {
bidRow = null;
}
const autobid = bidRow?.autobid !== false;
const bidCredits = campaign.bid_credits ?? DEFAULT_BID_CREDITS;
const history = await getBidHistory(serviceClient(), {
campaignId: id,
refSlug: campaign.ref_slug,
ownerId: user.id,
currentBid: bidCredits,
days: 30,
});

const impressions = (stats?.impressions as number) ?? 0;
const clicks = (stats?.clicks as number) ?? 0;
const freeImpressions = (stats?.free_impressions as number) ?? 0;
Expand Down Expand Up @@ -200,10 +236,35 @@ export default async function CampaignDetailPage({
</div>
)}

{/* The bid and its paper ledger. Paper is what the free-tier clicks would
have cost at the bid — real numbers, no money moved — so the auction
and the pacing can be read and judged before anybody is billed. */}
<div className="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-3">
<Stat
label={autobid ? "Bid (autobid)" : "Bid (set by hand)"}
value={`${bidCredits} cr`}
note={`$${((bidCredits * CREDIT_CENTS) / 100).toFixed(2)} per click`}
/>
<Stat
label="Paper spend today"
value={dollars(bidRow ? paperSpendTodayCents({ daily_budget_cents: campaign.daily_budget_cents, ...bidRow }, today) : 0)}
note="what free-tier clicks would have cost"
/>
<Stat
label="Paper spend total"
value={dollars(Number(bidRow?.paper_total_cents ?? 0))}
note="no credits were moved"
/>
</div>

<div className="mt-4">
<CampaignTrend data={daily} />
</div>

<div className="mt-4">
<BidHistory data={history.days} events={history.events} autobid={autobid} failed={history.failed} />
</div>

{creatives.length > 0 && (
<div className="mt-6">
<h2 className="mb-3 font-semibold">Creatives</h2>
Expand All @@ -223,11 +284,12 @@ export default async function CampaignDetailPage({
);
}

function Stat({ label, value }: { label: string; value: string }) {
function Stat({ label, value, note }: { label: string; value: string; note?: string }) {
return (
<div className="card p-4">
<div className="text-xs uppercase tracking-wider text-[var(--color-muted)]">{label}</div>
<div className="mt-1 text-2xl font-bold">{value}</div>
{note && <div className="mt-0.5 text-xs text-[var(--color-muted)]">{note}</div>}
</div>
);
}
26 changes: 10 additions & 16 deletions app/(app)/dashboard/ads/new/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ export function NewAdForm() {
const router = useRouter();
const [url, setUrl] = useState("");
const [budget, setBudget] = useState(5); // dollars/day
const [bid, setBid] = useState(0.2); // dollars/click (max bid)
// No bid field: the bid is automatic (see lib/ads/autobid.ts). The budget is
// the one number an advertiser sets; the edit page can switch to a typed bid.
const [name, setName] = useState("");
// Trending targeting, and with it the 90 days. Off by default: it changes
// where the ads run, and that is the advertiser's decision to make.
Expand Down Expand Up @@ -110,8 +111,7 @@ export function NewAdForm() {
name,
url,
dailyBudgetCents: Math.round(budget * 100),
// $ per click → credits (5¢/credit). Higher bids win more auctions.
bidCredits: Math.max(1, Math.round((bid * 100) / 5)),
// No bid: autobid (the default) sets it from the budget within the hour.
brand,
creatives,
trendingTopics: trending,
Expand Down Expand Up @@ -165,21 +165,15 @@ export function NewAdForm() {
<span className="text-sm text-[var(--color-muted)]">/day</span>
</div>
</div>
<div className="w-40">
<div className="w-56">
<label className="text-xs uppercase tracking-wider text-[var(--color-muted)]">
Max bid / click
Bid / click
</label>
<div className="mt-1 flex items-center gap-2">
<span className="text-[var(--color-muted)]">$</span>
<input
className="input"
type="number"
min={0.05}
step={0.05}
value={bid}
onChange={(e) => setBid(Math.max(0.05, Number(e.target.value)))}
/>
<span className="text-sm text-[var(--color-muted)]">/click</span>
<div className="mt-1 text-sm">
<span className="font-medium">Autobid</span>
<span className="block text-xs text-[var(--color-muted)]">
Set for you from the budget, hourly. Change it later on the campaign.
</span>
</div>
</div>
<button type="submit" className="btn btn-primary" disabled={generating}>
Expand Down
3 changes: 3 additions & 0 deletions app/(app)/dashboard/ads/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,9 @@ export default async function AdsPage({
label="Today"
value={`${dollars(spendTodayCents(c, today))} / ${dollars(c.daily_budget_cents)}`}
/>
{/* The bid autobid is making for it right now; the campaign
page has the history and the reasons. */}
<MiniStat label="Bid" value={`${c.bid_credits ?? 4} cr`} />
</div>
{!display.serving && (
<p className="mt-2 text-sm text-[var(--color-muted)]">{display.hint}</p>
Expand Down
4 changes: 2 additions & 2 deletions app/(marketing)/ads/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,8 @@ export default async function AdsMarketingPage() {

<div className="mt-6 grid gap-4 sm:grid-cols-2">
<Card
title="You are not bidding against a wall"
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."
title="You set a budget. The bid sets itself."
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."
/>
<Card
title="Clicks, counted conservatively"
Expand Down
47 changes: 44 additions & 3 deletions app/actions/ads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type { SiteBrand } from "@/lib/ads/brand";
import { MIN_PAYOUT_CENTS, DEFAULT_BID_CREDITS } from "@/lib/ads/pricing";
import { cleanTopics } from "@/lib/ads/trending";
import { grantTrendingPromo } from "@/lib/ads/promos";
import { recordManualBid } from "@/lib/ads/bids";
import { createCryptoPayout } from "@/lib/coinpay";

const ASSET_BUCKET = "ad-assets";
Expand Down Expand Up @@ -148,6 +149,8 @@ export async function saveCampaign(input: {
url: string;
dailyBudgetCents: number;
bidCredits?: number;
/** Off means "keep the bid I typed". Defaults to on: the controller bids. */
autobid?: boolean;
brand?: SiteBrand | null;
creatives: Partial<AdCreative>[];
summary?: Partial<AdSummary> | null;
Expand Down Expand Up @@ -189,6 +192,9 @@ export async function saveCampaign(input: {
brand: input.brand ?? {},
};
if (org.id) payload.organization_id = org.id;
// Autobid is the column default; only an explicit "keep my bid" is written,
// and it is dropped with the other optional columns on a schema-lag retry.
if (input.autobid === false) payload.autobid = false;
// Trending targeting, and the subjects it targets on. The subjects come from
// the page's own words rather than anything typed here: a campaign claiming
// topics its landing page never mentions is how contextual targeting gets
Expand Down Expand Up @@ -230,10 +236,11 @@ export async function saveCampaign(input: {
// of the schema. Retry without them rather than refusing to create the
// campaign: prose is an enhancement, a campaign that cannot be saved is the
// whole product failing. Same trade the impression short_code makes.
if (campaign.error && /summary_|trending_topics|topics|schema cache|column/i.test(campaign.error.message ?? "")) {
if (campaign.error && /summary_|trending_topics|topics|autobid|schema cache|column/i.test(campaign.error.message ?? "")) {
for (const key of Object.keys(summaryFields)) delete payload[key];
delete payload.trending_topics;
delete payload.topics;
delete payload.autobid;
campaign = await supabase
.from("ad_campaigns")
.insert(payload)
Expand Down Expand Up @@ -285,6 +292,8 @@ export async function updateCampaign(input: {
name?: string;
dailyBudgetCents?: number;
bidCredits?: number;
/** Hand the bid back to the controller (true) or keep the typed one (false). */
autobid?: boolean;
destinationUrl?: string;
}): Promise<{ ok: true } | { ok: false; error: string }> {
const supabase = await createClient();
Expand All @@ -300,9 +309,13 @@ export async function updateCampaign(input: {
if (Number.isFinite(input.dailyBudgetCents)) {
patch.daily_budget_cents = Math.max(0, Math.round(input.dailyBudgetCents!));
}
if (Number.isFinite(input.bidCredits)) {
// A bid typed while autobid is on would be overwritten within the hour, so
// it is only taken when the form is handing the bid to the person.
const manualBid = input.autobid !== true && Number.isFinite(input.bidCredits);
if (manualBid) {
patch.bid_credits = Math.min(200, Math.max(1, Math.round(input.bidCredits!)));
}
if (typeof input.autobid === "boolean") patch.autobid = input.autobid;
if (typeof input.destinationUrl === "string" && input.destinationUrl.trim()) {
const check = isAllowedTargetUrl(input.destinationUrl);
if (!check.ok) return { ok: false, error: check.reason };
Expand All @@ -311,12 +324,40 @@ export async function updateCampaign(input: {
}
if (Object.keys(patch).length === 0) return { ok: true };

const { error } = await supabase
// The bid before the edit, so the history can say what it moved from.
let prevBid: number | null = null;
if (manualBid) {
const { data: before } = await supabase
.from("ad_campaigns")
.select("bid_credits")
.eq("id", input.id)
.eq("owner_id", user.id)
.maybeSingle();
prevBid = (before?.bid_credits as number | null | undefined) ?? null;
}

let { error } = await supabase
.from("ad_campaigns")
.update(patch)
.eq("id", input.id)
.eq("owner_id", user.id);
// `autobid` rides behind a hand-applied migration; an edit to the name or
// the budget must still land if this deploy is ahead of the schema.
if (error && patch.autobid !== undefined && /autobid|schema cache|column/i.test(error.message ?? "")) {
delete patch.autobid;
if (Object.keys(patch).length === 0) return { ok: false, error: "Autobid is not available on this deployment yet." };
({ error } = await supabase.from("ad_campaigns").update(patch).eq("id", input.id).eq("owner_id", user.id));
}
if (error) return { ok: false, error: error.message };
if (manualBid && prevBid !== patch.bid_credits) {
await recordManualBid(serviceClient(), {
campaignId: input.id,
ownerId: user.id,
prevBidCredits: prevBid,
bidCredits: patch.bid_credits as number,
reason: "set in the dashboard",
});
}
revalidatePath("/dashboard/ads");
revalidatePath(`/dashboard/ads/${input.id}`);
return { ok: true };
Expand Down
Loading
Loading