diff --git a/app/(marketing)/ads/page.tsx b/app/(marketing)/ads/page.tsx
index 829a76aa..94c280d6 100644
--- a/app/(marketing)/ads/page.tsx
+++ b/app/(marketing)/ads/page.tsx
@@ -19,6 +19,8 @@ import {
FEED_ITEM_LABEL,
formatSpec,
} from "@/lib/ads/formats";
+import { AD_TEST_VARIANT_CREDITS } from "@/lib/ads/pricing";
+import { CREDIT_RACK_CENTS } from "@/lib/credits";
export const metadata = {
title: "Ads — advertise on the network, or get paid to run it",
@@ -120,6 +122,10 @@ export default async function AdsMarketingPage() {
title="Clicks, counted conservatively"
body="Bot traffic is not billed, and the same visitor clicking your campaign again inside six hours is not billed twice. When a click looks doubtful the visitor still gets through to your site; you just are not charged for it."
/>
+
+ Keep the current display ad as control, generate up to four AI alternatives, and
+ compare click-through rates. Visitors stay in one variant; video pre-roll keeps its
+ current creative during the test.
+
+ The existing creative is the free control. Generated alternatives are billed once;
+ normal campaign click charges still apply. Eligible display traffic is split evenly.
+
+
+ )}
+
+ {error &&
{error}
}
+
+ );
+}
diff --git a/lib/ads/candidates.ts b/lib/ads/candidates.ts
index e7b16b85..5be1066b 100644
--- a/lib/ads/candidates.ts
+++ b/lib/ads/candidates.ts
@@ -4,7 +4,7 @@ import type { AdFormatId } from "./creative";
// A page size, never a cap on the auction. The old LIMIT 100 excluded newer
// campaigns entirely once a format had more than 100 ready creatives.
const PAGE_SIZE = 500;
-const COLUMNS = "id, campaign_id, format, headline, body, cta_text, image_url, logo_url, bg_color, fg_color, accent_color, light_bg_color, light_fg_color, light_accent_color, font_family, ad_campaigns!inner(id, owner_id, status, ref_slug, destination_url, daily_budget_cents, spend_today_cents, spend_date, bid_credits)";
+const COLUMNS = "id, campaign_id, experiment_id, variant_id, format, headline, body, cta_text, image_url, logo_url, bg_color, fg_color, accent_color, light_bg_color, light_fg_color, light_accent_color, font_family, ad_campaigns!inner(id, owner_id, status, ref_slug, destination_url, daily_budget_cents, spend_today_cents, spend_date, bid_credits, active_experiment_id, default_variant_id)";
export async function loadServingCreatives(sb: SupabaseClient, format: AdFormatId) {
const rows = [];
diff --git a/lib/ads/creative.ts b/lib/ads/creative.ts
index 7f921508..4af85ee9 100644
--- a/lib/ads/creative.ts
+++ b/lib/ads/creative.ts
@@ -185,8 +185,15 @@ const SYSTEM_PROMPT = [
...SUMMARY_RULES,
].join(" ");
-function buildUserPrompt(brand: SiteBrand): string {
- return [
+type AdVariationRequest = {
+ instruction?: string;
+ control: Pick;
+ variantNumber: number;
+ variantCount: number;
+};
+
+function buildUserPrompt(brand: SiteBrand, variation?: AdVariationRequest): string {
+ const prompt = [
`Company site: ${brand.url}`,
`Title: ${brand.title || "(none)"}`,
brand.description ? `Description: ${brand.description}` : "",
@@ -195,9 +202,23 @@ function buildUserPrompt(brand: SiteBrand): string {
"",
"Page content:",
brand.text || "(no readable text found — infer from the domain and title)",
- ]
- .filter(Boolean)
- .join("\n");
+ ];
+
+ if (variation) {
+ prompt.push(
+ "",
+ "Paid A/B test variation:",
+ `Control headline: ${variation.control.headline}`,
+ `Control benefit line: ${variation.control.body}`,
+ `Control CTA: ${variation.control.ctaText}`,
+ `This is alternative ${variation.variantNumber} of ${variation.variantCount - 1}. Make a meaningfully different copy angle while preserving the facts and brand shown above.`,
+ variation.instruction
+ ? `Advertiser direction: ${variation.instruction}`
+ : "Advertiser direction: none. Choose a distinct, evidence-based benefit or CTA angle.",
+ );
+ }
+
+ return prompt.filter(Boolean).join("\n");
}
/**
@@ -467,7 +488,7 @@ function describesTheFetch(text: string): boolean {
export async function generateAdCreatives(
rawUrl: string,
- opts: { supabase?: SupabaseClient } = {},
+ opts: { supabase?: SupabaseClient; variation?: AdVariationRequest } = {},
): Promise<{
brand: SiteBrand;
creatives: AdCreative[];
@@ -482,8 +503,13 @@ export async function generateAdCreatives(
const { provider, output } = await generateStructuredOutput({
name: "ad_copy",
schema: CopySchema,
- system: SYSTEM_PROMPT,
- user: buildUserPrompt(brand),
+ system: opts.variation
+ ? [
+ SYSTEM_PROMPT,
+ "This is a paid ad-test alternative to the supplied control. Make the copy concept meaningfully different, but do not contradict the control's factual claims or invent details absent from the site. Follow the advertiser direction when it is compatible with the source facts. Keep the same brand and product; vary only the message angle and CTA. The arm number exists to keep repeated generations distinct.",
+ ].join(" ")
+ : SYSTEM_PROMPT,
+ user: buildUserPrompt(brand, opts.variation),
// Reasoning models (gpt-5-mini) spend output budget on reasoning before the
// structured copy, so keep generous headroom even though the copy is tiny.
maxTokens: 3000,
diff --git a/lib/ads/pricing.ts b/lib/ads/pricing.ts
index d9dfc012..0a026995 100644
--- a/lib/ads/pricing.ts
+++ b/lib/ads/pricing.ts
@@ -26,6 +26,10 @@ export const CPC_CREDITS = 4;
export const DEFAULT_BID_CREDITS = CPC_CREDITS;
export const CPC_CENTS = CPC_CREDITS * CREDIT_CENTS;
+// A generated arm is text-only and reuses the campaign's current visual design.
+// Keep its one-time setup price separate from the much heavier scan price.
+export const AD_TEST_VARIANT_CREDITS = 5;
+
// Trending-topic premium: the rate a trending-targeted click is quoted at,
// in CENTS, and the rate the 90-day promo is free of. Two cents, stated here
// rather than in a marketing page so the dashboard, the CLI and the promo row
diff --git a/lib/ads/serve.ts b/lib/ads/serve.ts
index 2b75cfaf..24bb2f6b 100644
--- a/lib/ads/serve.ts
+++ b/lib/ads/serve.ts
@@ -98,6 +98,8 @@ export function isAdFormat(v: string | null | undefined): v is AdFormatId {
type CreativeRow = {
id: string;
campaign_id: string;
+ experiment_id?: string | null;
+ variant_id?: string | null;
format: AdFormatId;
headline: string;
body: string;
@@ -286,6 +288,8 @@ export async function serveAd(
spend_today_cents: number;
spend_date: string | null;
bid_credits: number;
+ active_experiment_id: string | null;
+ default_variant_id: string | null;
};
type Row = CreativeRow & { ad_campaigns: CampaignJoin | CampaignJoin[] };
const oneCampaign = (c: CampaignJoin | CampaignJoin[]): CampaignJoin =>
@@ -303,9 +307,46 @@ export async function serveAd(
// So self-owned campaigns are demoted rather than discarded, below. Between a
// real advertiser's creative and the house ad — neither of which can earn on
// this request — the real creative is strictly the better fill.
- const candidates = (creatives as unknown as Row[]).filter(
+ const loadedRows = (creatives as unknown as Row[]).filter(
(row) => !!oneCampaign(row.ad_campaigns),
);
+ // Keep a visitor in the same arm and give each campaign one auction entry.
+ // Without collapsing the variants here, an N-arm test would multiply that
+ // campaign's auction weight by N. Video placements currently have no static
+ // creative variant, so they continue using the campaign's default creative.
+ const groupedRows = new Map();
+ for (const row of loadedRows) {
+ const campaign = oneCampaign(row.ad_campaigns);
+ const group = groupedRows.get(campaign.id) ?? [];
+ group.push(row);
+ groupedRows.set(campaign.id, group);
+ }
+ const candidates: Row[] = [];
+ for (const rows of groupedRows.values()) {
+ const campaign = oneCampaign(rows[0].ad_campaigns);
+ let eligible: Row[] = [];
+ if (campaign.active_experiment_id) {
+ eligible = rows.filter((row) => row.experiment_id === campaign.active_experiment_id);
+ if (eligible.length > 0) {
+ const variantIds = [...new Set(eligible.map((row) => row.variant_id).filter(Boolean) as string[])].sort();
+ if (variantIds.length > 0) {
+ const seed = ctx.visitorId?.slice(0, 256) || ctx.ip?.slice(0, 128) || crypto.randomUUID();
+ const digest = crypto
+ .createHash("sha256")
+ .update(`${campaign.id}:${campaign.active_experiment_id}:${seed}`)
+ .digest();
+ const assigned = variantIds[digest.readUInt32BE(0) % variantIds.length];
+ eligible = eligible.filter((row) => row.variant_id === assigned);
+ }
+ }
+ }
+ if (eligible.length === 0) {
+ eligible = campaign.default_variant_id
+ ? rows.filter((row) => row.variant_id === campaign.default_variant_id)
+ : rows.filter((row) => !row.experiment_id);
+ }
+ if (eligible.length > 0) candidates.push(eligible[0]);
+ }
if (candidates.length === 0) return houseFill(format, theme);
// A campaign is PAID-eligible only if its owner can actually cover a click at
diff --git a/supabase/migrations/20261030120000_ad_creative_experiments.sql b/supabase/migrations/20261030120000_ad_creative_experiments.sql
new file mode 100644
index 00000000..bcfa46b9
--- /dev/null
+++ b/supabase/migrations/20261030120000_ad_creative_experiments.sql
@@ -0,0 +1,135 @@
+-- Paid creative experiments for advertiser campaigns.
+--
+-- Each test copies the campaign's current creative set into a control arm and
+-- generates one or more text variants. Serving assigns a visitor to one arm for
+-- the life of the test; impression and click rows already point at creatives,
+-- so their existing IDs provide variant-level reporting without changing the
+-- metering path.
+
+create table if not exists public.ad_experiments (
+ id uuid primary key default gen_random_uuid(),
+ campaign_id uuid not null references public.ad_campaigns(id) on delete cascade,
+ owner_id uuid not null references auth.users(id) on delete cascade,
+ name text not null default 'Creative test',
+ status text not null default 'draft'
+ check (status in ('draft','running','paused','completed')),
+ instruction text,
+ variant_count integer not null check (variant_count between 2 and 5),
+ credits_charged integer not null default 0 check (credits_charged >= 0),
+ winner_variant_id uuid,
+ created_at timestamptz not null default now(),
+ started_at timestamptz,
+ completed_at timestamptz
+);
+
+create index if not exists ad_experiments_campaign_created_idx
+ on public.ad_experiments(campaign_id, created_at desc);
+
+create table if not exists public.ad_experiment_variants (
+ id uuid primary key default gen_random_uuid(),
+ experiment_id uuid not null references public.ad_experiments(id) on delete cascade,
+ campaign_id uuid not null references public.ad_campaigns(id) on delete cascade,
+ owner_id uuid not null references auth.users(id) on delete cascade,
+ variant_key text not null,
+ label text not null,
+ is_control boolean not null default false,
+ instruction text,
+ created_at timestamptz not null default now(),
+ unique (experiment_id, variant_key)
+);
+
+do $$ begin
+ if not exists (
+ select 1 from pg_constraint
+ where conname = 'ad_experiments_winner_variant_fk'
+ and conrelid = 'public.ad_experiments'::regclass
+ ) then
+ alter table public.ad_experiments
+ add constraint ad_experiments_winner_variant_fk
+ foreign key (winner_variant_id)
+ references public.ad_experiment_variants(id)
+ on delete set null;
+ end if;
+end $$;
+
+create index if not exists ad_experiment_variants_experiment_idx
+ on public.ad_experiment_variants(experiment_id);
+
+alter table public.ad_campaigns
+ add column if not exists active_experiment_id uuid
+ references public.ad_experiments(id) on delete set null,
+ add column if not exists default_variant_id uuid
+ references public.ad_experiment_variants(id) on delete set null;
+
+alter table public.ad_creatives
+ add column if not exists experiment_id uuid
+ references public.ad_experiments(id) on delete cascade,
+ add column if not exists variant_id uuid
+ references public.ad_experiment_variants(id) on delete cascade;
+
+create index if not exists ad_creatives_experiment_idx
+ on public.ad_creatives(experiment_id, variant_id, format);
+
+create index if not exists ad_impressions_creative_idx
+ on public.ad_impressions(creative_id);
+
+create index if not exists ad_clicks_creative_idx
+ on public.ad_clicks(creative_id);
+
+alter table public.ad_experiments enable row level security;
+alter table public.ad_experiment_variants enable row level security;
+
+drop policy if exists "own ad experiments" on public.ad_experiments;
+create policy "own ad experiments" on public.ad_experiments
+ for all using (owner_id = auth.uid()) with check (owner_id = auth.uid());
+
+drop policy if exists "own ad experiment variants" on public.ad_experiment_variants;
+create policy "own ad experiment variants" on public.ad_experiment_variants
+ for all using (owner_id = auth.uid()) with check (owner_id = auth.uid());
+
+-- Credits are charged before generating alternatives. This refund helper is
+-- atomic so a failed provider/database write cannot race another credit spend.
+create or replace function public.refund_credit(p_owner uuid, p_count int)
+returns void
+language sql security definer set search_path = public as $$
+ update public.profiles
+ set credits_balance = credits_balance + greatest(p_count, 0)
+ where id = p_owner;
+$$;
+
+revoke all on function public.refund_credit(uuid, int) from public, anon, authenticated;
+grant execute on function public.refund_credit(uuid, int) to service_role;
+
+-- Aggregated results stay in PostgreSQL rather than returning a capped page of
+-- impression/click rows to the dashboard. auth.uid() scopes the security
+-- definer to the campaign owner, while ad creatives remain the attribution key.
+create or replace function public.ad_experiment_variant_stats(p_experiment uuid)
+returns table (variant_id uuid, impressions bigint, clicks bigint)
+language sql stable security definer set search_path = public as $$
+ select
+ v.id as variant_id,
+ (
+ select count(*)
+ from public.ad_creatives creative
+ join public.ad_impressions impression on impression.creative_id = creative.id
+ where creative.experiment_id = e.id
+ and creative.variant_id = v.id
+ and not coalesce(impression.duplicate, false)
+ ) as impressions,
+ (
+ select count(*)
+ from public.ad_creatives creative
+ join public.ad_clicks click on click.creative_id = creative.id
+ where creative.experiment_id = e.id
+ and creative.variant_id = v.id
+ and click.valid
+ ) as clicks
+ from public.ad_experiment_variants v
+ join public.ad_experiments e on e.id = v.experiment_id
+ where e.id = p_experiment
+ and e.owner_id = auth.uid()
+ order by v.created_at;
+$$;
+
+revoke all on function public.ad_experiment_variant_stats(uuid) from public, anon;
+grant execute on function public.ad_experiment_variant_stats(uuid) to authenticated;