From 9f27fe73628883e6b86892084e09d5773aac7717 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 17 Jul 2026 07:14:48 +0000 Subject: [PATCH] =?UTF-8?q?ads:=20surface=20the=20Leaderboard=20(728=C3=97?= =?UTF-8?q?90)=20size=20on=20the=20Monetize=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Monetize page only ever handed publishers the 300×250 square embed, even though the serving pipeline already renders leaderboards. Add a data-driven size picker (PUBLISHER_FORMAT_IDS) with a toggle button per size that reveals that size's copyable embed, and thread the chosen format through the one-click installer so the PR it opens installs the size the publisher actually picked. Placement: a 728×90 reads best across the top of the page, so the installer now drops the leaderboard right after instead of before (falling back to the old spot when there's no tag). - lib/ads/formats.ts: PUBLISHER_FORMAT_IDS allowlist (rect + leaderboard) - components/ads/slot-manager.tsx: per-size toggle buttons + copy - lib/github/install-ad.ts: injectAfterBodyOpen + per-format placement - app/api/ads/slots/[id]/install-embed: accept & validate `format` - tests: leaderboard lands at the top of the page Co-Authored-By: Claude Opus 4.8 --- app/api/ads/slots/[id]/install-embed/route.ts | 4 ++ components/ads/slot-manager.tsx | 67 ++++++++++++++----- lib/ads/formats.ts | 6 ++ lib/github/install-ad.ts | 34 +++++++++- tests/contract/install-ad.test.ts | 23 +++++++ 5 files changed, 117 insertions(+), 17 deletions(-) diff --git a/app/api/ads/slots/[id]/install-embed/route.ts b/app/api/ads/slots/[id]/install-embed/route.ts index 65d77da8..5aa76c84 100644 --- a/app/api/ads/slots/[id]/install-embed/route.ts +++ b/app/api/ads/slots/[id]/install-embed/route.ts @@ -15,6 +15,7 @@ import { createClient } from "@/lib/supabase/server"; import { serviceClient } from "@/lib/supabase/service"; import { getOrMintInstallationToken } from "@/lib/github/installations"; import { installAdEmbed } from "@/lib/github/install-ad"; +import { AD_FORMAT_IDS } from "@/lib/ads/formats"; export const runtime = "nodejs"; @@ -23,6 +24,8 @@ const bodySchema = z.object({ repo: z.string().min(1).optional(), installation_id: z.number().int().positive().optional(), target_path: z.string().max(500).optional(), + // Which ad size to install. Defaults to the medium rectangle in the installer. + format: z.enum(AD_FORMAT_IDS as [string, ...string[]]).optional(), }); type BoundRepo = { @@ -141,6 +144,7 @@ export async function POST(request: NextRequest, ctx: { params: Promise<{ id: st owner: owner!, repo: repo!, slotId, + format: body.format, targetPath: body.target_path, }); await finalize({ diff --git a/components/ads/slot-manager.tsx b/components/ads/slot-manager.tsx index ea476215..01a1ad50 100644 --- a/components/ads/slot-manager.tsx +++ b/components/ads/slot-manager.tsx @@ -3,6 +3,13 @@ import { useState, useTransition } from "react"; import { useRouter } from "next/navigation"; import { createSlot, setSlotStatus, saveSlotPayout, requestPayout } from "@/app/actions/ads"; +import { PUBLISHER_FORMAT_IDS, formatSpec, type AdFormatId } from "@/lib/ads/formats"; + +// The paste-once embed for a given size. data-format tells /ad.js which creative +// to request; the medium rectangle stays the default the auto-installer uses. +function embedFor(slotId: string, format: AdFormatId, origin: string): string { + return `
\n`; +} type Project = { id: string; name: string; url: string }; type Slot = { @@ -73,15 +80,16 @@ export function SlotManager({ const [addr, setAddr] = useState(slot?.payout_address ?? ""); const [currency, setCurrency] = useState(slot?.payout_currency ?? "usdc_pol"); const [copied, setCopied] = useState(false); + // Which size's embed is currently revealed, and whether the code block is open. + const [fmt, setFmt] = useState(PUBLISHER_FORMAT_IDS[0]); + const [showCode, setShowCode] = useState(true); const [prBusy, setPrBusy] = useState(false); const [prMsg, setPrMsg] = useState<{ ok: boolean; text: string; url?: string } | null>(null); const [repoChoices, setRepoChoices] = useState< { owner: string; repo: string; installation_id: number }[] | null >(null); - const embed = - slot && - `
\n`; + const embed = slot ? embedFor(slot.id, fmt, origin) : null; function enable() { start(async () => { @@ -155,7 +163,7 @@ export function SlotManager({ const res = await fetch(`/api/ads/slots/${slot.id}/install-embed`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(pick ?? {}), + body: JSON.stringify({ ...(pick ?? {}), format: fmt }), }); const json = await res.json(); if (!res.ok) { @@ -229,19 +237,48 @@ export function SlotManager({ )}
- Embed — paste on your page + Embed — pick a size, paste on your page
-
-              {embed}
-            
-
- - + {/* One button per available size. Clicking reveals that size's code; + clicking the open size again collapses it. */} +
+ {PUBLISHER_FORMAT_IDS.map((id) => { + const spec = formatSpec(id); + const open = id === fmt && showCode; + return ( + + ); + })}
+ {showCode && embed && ( + <> +
+                  {embed}
+                
+
+ + +
+ + )} {repoChoices && (
diff --git a/lib/ads/formats.ts b/lib/ads/formats.ts index a0114683..391c896a 100644 --- a/lib/ads/formats.ts +++ b/lib/ads/formats.ts @@ -15,6 +15,12 @@ export const AD_FORMATS = [ export type AdFormatId = (typeof AD_FORMATS)[number]["id"]; export const AD_FORMAT_IDS = AD_FORMATS.map((f) => f.id) as AdFormatId[]; +// Sizes offered to publishers on the Monetize page — the ones they can copy an +// embed for and install. A subset of AD_FORMATS that grows as each size is +// surfaced (one PR per size). Keep the medium rectangle first: it's the safe +// default the auto-installer falls back to. +export const PUBLISHER_FORMAT_IDS: AdFormatId[] = ["banner_300x250", "banner_728x90"]; + export function formatSpec(id: AdFormatId) { return AD_FORMATS.find((f) => f.id === id) ?? AD_FORMATS[0]; } diff --git a/lib/github/install-ad.ts b/lib/github/install-ad.ts index 426165e1..9d084f62 100644 --- a/lib/github/install-ad.ts +++ b/lib/github/install-ad.ts @@ -134,6 +134,36 @@ function injectBeforeBodyClose(content: string, embed: string, path: string): st return updated; } +// Inject right after the opening tag — the "right place" for a +// leaderboard, which reads best across the top of the page rather than jammed +// at the very bottom before . Best-effort: callers fall back to +// injectBeforeBodyClose when there's no tag (e.g. a React fragment). +function injectAfterBodyOpen(content: string, embed: string, path: string): string | null { + const match = content.match(/]*>/i); + if (!match || match.index == null) return null; + const openEnd = match.index + match[0].length; + const lineStart = content.lastIndexOf("\n", match.index) + 1; + const indent = content.slice(lineStart, match.index).match(/^\s*/)?.[0] ?? ""; + let updated = `${content.slice(0, openEnd)}\n${indent} ${embed}${content.slice(openEnd)}`; + if (isJsx(path) && /. +const TOP_PLACED_FORMATS = new Set(["banner_728x90"]); + +// Choose where a format's embed lands. Leaderboards go up top; everything else +// (rectangle, mobile, text link) drops in before . Always falls back to +// the other strategy so a missing / never blocks the install. +function injectEmbed(content: string, embed: string, path: string, format: string): string | null { + if (TOP_PLACED_FORMATS.has(format)) { + return injectAfterBodyOpen(content, embed, path) ?? injectBeforeBodyClose(content, embed, path); + } + return injectBeforeBodyClose(content, embed, path); +} + export async function installAdEmbed(input: InstallAdInput): Promise { const format = input.format ?? DEFAULT_FORMAT; const repoMeta = await getRepo({ token: input.token, owner: input.owner, repo: input.repo }); @@ -171,9 +201,9 @@ export async function installAdEmbed(input: InstallAdInput): Promise tag in ${file.path}.` }; + return { status: "noop", path: file.path, detail: `No tag in ${file.path}.` }; } // Patch the site's CSP so the browser can load /ad.js, reach /api/ads/serve, diff --git a/tests/contract/install-ad.test.ts b/tests/contract/install-ad.test.ts index 590a8d1a..63a57d2f 100644 --- a/tests/contract/install-ad.test.ts +++ b/tests/contract/install-ad.test.ts @@ -130,6 +130,29 @@ describe("installAdEmbed", () => { expect(written).toContain("next.config.ts"); }); + it("places a leaderboard at the top of the page (after ), not before ", async () => { + github.files.set( + "app/layout.tsx", + "export default function RootLayout({ children }) {\n return
{children}
;\n}\n", + ); + + await installAdEmbed({ + token: "token", + owner: "owner", + repo: "repo", + slotId: "slot-abc", + format: "banner_728x90", + }); + + const write = github.putFile.mock.calls.find((c) => c[0].path === "app/layout.tsx"); + expect(write).toBeDefined(); + const content = write![0].contentUtf8 as string; + // The embed carries the requested format and lands before
, i.e. right + // after rather than at the very bottom of the page. + expect(content).toContain('data-format="banner_728x90"'); + expect(content.indexOf("data-cp-ad")).toBeLessThan(content.indexOf("
")); + }); + it("no-ops when the embed exists and no CSP needs changes", async () => { github.files.set( "app/layout.tsx",