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
74 changes: 70 additions & 4 deletions app/(app)/dashboard/ads/new/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@

import { useState, useTransition, useRef } from "react";
import { useRouter } from "next/navigation";
import { previewAds, saveCampaign, uploadAdAsset } from "@/app/actions/ads";
import { draftAds, saveCampaign, uploadAdAsset, type previewAds } from "@/app/actions/ads";
import { PAGE_COPY_PROVIDER } from "@/lib/ads/agent";
import { DESIGN_FORMATS, paletteFor, type AdCreative, type AdFormatId } from "@/lib/ads/formats";
import type { SiteBrand } from "@/lib/ads/brand";
import { AdPreview } from "@/components/ads/ad-preview";
import { ColorField } from "@/components/ads/color-field";
import type { AdTheme } from "@/lib/ads/theme";
import { originalHeroPngDownloadUrl } from "@/lib/ads/heroImageUrl";

type Preview = Extract<Awaited<ReturnType<typeof previewAds>>, { ok: true }>;

function dollars(cents: number): string {
return `$${(cents / 100).toFixed(2)}`;
}
Expand All @@ -28,6 +31,12 @@ export function NewAdForm() {
const [creatives, setCreatives] = useState<AdCreative[]>([]);
const [active, setActive] = useState<AdFormatId>("banner_300x250");
const [provider, setProvider] = useState<string | null>(null);
// The model's copy arrives a minute or two after the draft.
const [improving, setImproving] = useState(false);
// Held back instead of applied, because the advertiser edited the draft.
const [readyCopy, setReadyCopy] = useState<Preview | null>(null);
const editedRef = useRef(false);
const improveRef = useRef<AbortController | null>(null);

const [generating, startGenerate] = useTransition();
const [saving, startSave] = useTransition();
Expand All @@ -47,23 +56,61 @@ export function NewAdForm() {
e.preventDefault();
setError(null);
startGenerate(async () => {
const res = await previewAds({ url });
const res = await draftAds({ url });
if (!res.ok) {
setError(res.error);
return;
}
setBrand(res.brand);
setCreatives(res.creatives);
setProvider(res.provider);
setProvider(PAGE_COPY_PROVIDER);
setReadyCopy(null);
editedRef.current = false;
if (!name) setName(res.suggestedName);
void improve(url);
});
}

function applyCopy(res: Preview) {
setBrand(res.brand);
setCreatives(res.creatives);
setProvider(res.provider);
setReadyCopy(null);
editedRef.current = false;
}

// A failure here is not an error on the page: the draft is a working ad and
// stays, under the label that says it was written from the page.
async function improve(target: string) {
improveRef.current?.abort();
const controller = new AbortController();
improveRef.current = controller;
setImproving(true);
try {
const res = await fetch("/api/ads/preview", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ url: target }),
signal: controller.signal,
});
const out = (await res.json()) as Awaited<ReturnType<typeof previewAds>>;
if (controller.signal.aborted || !out.ok || out.provider === PAGE_COPY_PROVIDER) return;
if (editedRef.current) setReadyCopy(out);
else applyCopy(out);
} catch {
// Aborted by a newer run or a save, or the connection dropped.
} finally {
if (improveRef.current === controller) setImproving(false);
}
}

// copy edits touch only the active format; colours/assets are shared brand-level
function patchActive(patch: Partial<AdCreative>) {
editedRef.current = true;
setCreatives((cs) => cs.map((c) => (c.format === active ? { ...c, ...patch } : c)));
}
function patchAll(patch: Partial<AdCreative>) {
editedRef.current = true;
setCreatives((cs) => cs.map((c) => ({ ...c, ...patch })));
}

Expand All @@ -75,6 +122,7 @@ export function NewAdForm() {
const field = key === "bg" ? "bgColor" : key === "fg" ? "fgColor" : "accentColor";
return patchAll({ [field]: v } as Partial<AdCreative>);
}
editedRef.current = true;
setCreatives((cs) =>
cs.map((c) => {
const seed = paletteFor(c, "light");
Expand Down Expand Up @@ -108,6 +156,7 @@ export function NewAdForm() {

function save() {
setError(null);
improveRef.current?.abort();
startSave(async () => {
const res = await saveCampaign({
name,
Expand Down Expand Up @@ -217,7 +266,11 @@ export function NewAdForm() {
<h2 className="font-semibold">Preview</h2>
{provider && (
<span className="text-xs text-[var(--color-muted)]">
Generated with {provider}
{improving
? "Draft from your page. AI copy is on the way, usually a minute or two; you can edit or save now."
: provider === PAGE_COPY_PROVIDER
? "AI unavailable, written from your page. Edit before saving."
: `Generated with ${provider}`}
</span>
)}
</div>
Expand All @@ -230,6 +283,19 @@ export function NewAdForm() {
A five-second streaming pre-roll is rendered from this design when you
save. It appears on the campaign page, with an MP4 download, once ready.
</div>
{readyCopy && (
<div className="flex items-center justify-between gap-3 text-xs">
<span>AI copy is ready. Using it replaces your edits.</span>
<span className="flex gap-2">
<button type="button" className="btn" onClick={() => applyCopy(readyCopy)}>
Use AI copy
</button>
<button type="button" className="btn" onClick={() => setReadyCopy(null)}>
Keep mine
</button>
</span>
</div>
)}
<div className="flex flex-wrap items-start gap-6">
{DESIGN_FORMATS.map((f) => {
const c = creatives.find((x) => x.format === f.id);
Expand Down
27 changes: 27 additions & 0 deletions app/actions/ads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
type RenderState,
} from "@/lib/ads/video/jobs";
import {
draftAdCreatives,
generateAdCreatives,
DESIGN_FORMAT_IDS,
cleanSummary,
Expand Down Expand Up @@ -81,6 +82,30 @@ function summaryPayload(
};
}

// The instant first draft for /dashboard/ads/new: the page's own words, no
// model. The form then asks POST /api/ads/preview for the generated version.
export async function draftAds(input: { url: string }): Promise<
| { ok: true; brand: SiteBrand; creatives: AdCreative[]; suggestedName: string }
| { ok: false; error: string }
> {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return { ok: false, error: "Not authenticated." };

const check = isAllowedTargetUrl(input.url);
if (!check.ok) return { ok: false, error: check.reason };

try {
const { brand, creatives } = await draftAdCreatives(check.url);
if (creatives.length === 0) return { ok: false, error: "Nothing on that page to write an ad from." };
return { ok: true, brand, creatives, suggestedName: brand.title?.slice(0, 60) || domainOf(check.url) };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : "Could not read that URL." };
}
}

// Auto-generate ad creatives from a destination URL. No DB write — the client
// holds the result, lets the user edit/replace, then calls saveCampaign.
export async function previewAds(input: { url: string }): Promise<
Expand All @@ -106,7 +131,9 @@ export async function previewAds(input: { url: string }): Promise<
try {
const { brand, creatives, provider, summary } = await generateAdCreatives(check.url, {
supabase,
pageCopyOnFailure: true,
});
if (creatives.length === 0) return { ok: false, error: "Nothing on that page to write an ad from." };
return {
ok: true,
brand,
Expand Down
15 changes: 15 additions & 0 deletions app/api/ads/preview/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { NextResponse, type NextRequest } from "next/server";
import { previewAds } from "@/app/actions/ads";

// A route and not a server action on purpose: Next runs a client's server
// actions one at a time, and this takes minutes on c0mpute. As an action it
// would hold back every Save and logo upload on the form behind it.
export async function POST(req: NextRequest) {
const body = (await req.json().catch(() => null)) as { url?: unknown } | null;
if (typeof body?.url !== "string") {
return NextResponse.json({ ok: false, error: "url is required." }, { status: 400 });
}
const result = await previewAds({ url: body.url });
const status = result.ok || result.error !== "Not authenticated." ? 200 : 401;
return NextResponse.json(result, { status });
}
6 changes: 4 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions lib/ads/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@

export const AD_GENERATOR_AGENT = "ad-generator-agent";

/** The `provider` of ads written from the page because no model answered. */
export const PAGE_COPY_PROVIDER = "page text";

type Env = Record<string, string | undefined>;

// Read through a variable so nothing here can be inlined at build time.
Expand Down
Loading
Loading