Skip to content

Commit 0b6c878

Browse files
ralyodioclaude
andcommitted
Campaign editing + .onion Tor routing
Campaign edit: new /ads/[id] edit page + updateCampaign/updateCreatives actions (owner-scoped). Edit name, destination URL, daily budget, max bid/click, and per-format creative copy/colours/logo with live preview. "Edit" link added to each campaign in the list. .onion routing: lib/onion.ts detects v3 .onion hosts and routes their fetches through a Tor SOCKS5 proxy (TOR_SOCKS_URL, socks5h so Tor resolves the host). Wired into the audit fetcher, ad brand extraction, and logo discovery. Without TOR_SOCKS_URL, .onion fetches fail fast with a clear message; everything else is a normal fetch. Adds the `socks` dep. Refactor: moved pure format constants/types to lib/ads/formats.ts so client components don't drag the LLM/Tor stack (socks→node:net) into the browser bundle. Verified: onion detection + graceful no-Tor error; full production build passes with /ads/[id] compiled. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent bd7b46e commit 0b6c878

15 files changed

Lines changed: 544 additions & 44 deletions

File tree

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

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
"use client";
2+
3+
import { useState, useTransition } from "react";
4+
import { useRouter } from "next/navigation";
5+
import { updateCampaign, updateCreatives, uploadAdAsset } from "@/app/actions/ads";
6+
import { AD_FORMATS, type AdCreative, type AdFormatId } from "@/lib/ads/formats";
7+
import { AdPreview } from "@/components/ads/ad-preview";
8+
9+
type Campaign = {
10+
id: string;
11+
name: string;
12+
destinationUrl: string;
13+
dailyBudgetCents: number;
14+
bidCredits: number;
15+
status: string;
16+
};
17+
18+
export function EditCampaignForm({
19+
campaign,
20+
creatives: initial,
21+
}: {
22+
campaign: Campaign;
23+
creatives: (AdCreative & { id: string })[];
24+
}) {
25+
const router = useRouter();
26+
const [saving, startSave] = useTransition();
27+
const [error, setError] = useState<string | null>(null);
28+
const [saved, setSaved] = useState(false);
29+
30+
const [name, setName] = useState(campaign.name);
31+
const [url, setUrl] = useState(campaign.destinationUrl);
32+
const [budget, setBudget] = useState(campaign.dailyBudgetCents / 100);
33+
const [bid, setBid] = useState((campaign.bidCredits * 5) / 100); // credits → $
34+
const [creatives, setCreatives] = useState(initial);
35+
const [active, setActive] = useState<AdFormatId>(initial[0]?.format ?? "banner_300x250");
36+
const [uploading, setUploading] = useState(false);
37+
38+
const current = creatives.find((c) => c.format === active) ?? creatives[0];
39+
40+
function patchActive(patch: Partial<AdCreative>) {
41+
setCreatives((cs) => cs.map((c) => (c.format === active ? { ...c, ...patch } : c)));
42+
}
43+
function patchAll(patch: Partial<AdCreative>) {
44+
setCreatives((cs) => cs.map((c) => ({ ...c, ...patch })));
45+
}
46+
47+
async function onUploadLogo(e: React.ChangeEvent<HTMLInputElement>) {
48+
const file = e.target.files?.[0];
49+
if (!file) return;
50+
setUploading(true);
51+
const fd = new FormData();
52+
fd.set("file", file);
53+
const res = await uploadAdAsset(fd);
54+
setUploading(false);
55+
if (!res.ok) return setError(res.error);
56+
patchAll({ logoUrl: res.url });
57+
}
58+
59+
function save() {
60+
setError(null);
61+
setSaved(false);
62+
startSave(async () => {
63+
const s = await updateCampaign({
64+
id: campaign.id,
65+
name,
66+
destinationUrl: url,
67+
dailyBudgetCents: Math.round(budget * 100),
68+
bidCredits: Math.max(1, Math.round((bid * 100) / 5)),
69+
});
70+
if (!s.ok) return setError(s.error);
71+
const c = await updateCreatives({
72+
campaignId: campaign.id,
73+
creatives: creatives.map((cr) => ({ ...cr })),
74+
});
75+
if (!c.ok) return setError(c.error);
76+
setSaved(true);
77+
router.refresh();
78+
});
79+
}
80+
81+
return (
82+
<div className="mt-6 space-y-6">
83+
{error && <div className="card border-red-500/40 p-3 text-sm text-red-400">{error}</div>}
84+
{saved && <div className="card border-[var(--color-accent)]/40 p-3 text-sm text-[var(--color-accent)]">Saved.</div>}
85+
86+
{/* Settings */}
87+
<div className="card space-y-4 p-5">
88+
<h2 className="font-semibold">Settings</h2>
89+
<div className="grid gap-4 sm:grid-cols-2">
90+
<label className="block">
91+
<span className="text-xs uppercase tracking-wider text-[var(--color-muted)]">Name</span>
92+
<input className="input mt-1" value={name} onChange={(e) => setName(e.target.value)} />
93+
</label>
94+
<label className="block">
95+
<span className="text-xs uppercase tracking-wider text-[var(--color-muted)]">Destination URL</span>
96+
<input className="input mt-1" type="url" value={url} onChange={(e) => setUrl(e.target.value)} />
97+
</label>
98+
<label className="block">
99+
<span className="text-xs uppercase tracking-wider text-[var(--color-muted)]">Daily budget ($)</span>
100+
<input
101+
className="input mt-1"
102+
type="number"
103+
min={1}
104+
step={1}
105+
value={budget}
106+
onChange={(e) => setBudget(Math.max(0, Number(e.target.value)))}
107+
/>
108+
</label>
109+
<label className="block">
110+
<span className="text-xs uppercase tracking-wider text-[var(--color-muted)]">Max bid / click ($)</span>
111+
<input
112+
className="input mt-1"
113+
type="number"
114+
min={0.05}
115+
step={0.05}
116+
value={bid}
117+
onChange={(e) => setBid(Math.max(0.05, Number(e.target.value)))}
118+
/>
119+
</label>
120+
</div>
121+
</div>
122+
123+
{/* Creative editor */}
124+
{current && (
125+
<div className="card space-y-4 p-5">
126+
<div className="flex flex-wrap items-start gap-4">
127+
{AD_FORMATS.map((f) => {
128+
const c = creatives.find((x) => x.format === f.id);
129+
if (!c) return null;
130+
return (
131+
<button
132+
key={f.id}
133+
onClick={() => setActive(f.id)}
134+
className={`rounded-lg p-2 ${active === f.id ? "ring-2 ring-[var(--color-accent)]" : "opacity-80 hover:opacity-100"}`}
135+
style={{ maxWidth: f.w > 360 ? 360 : f.w + 16 }}
136+
>
137+
<div style={{ overflowX: "auto" }}>
138+
<AdPreview creative={c} />
139+
</div>
140+
</button>
141+
);
142+
})}
143+
</div>
144+
145+
<div className="grid gap-4 sm:grid-cols-2">
146+
<label className="block">
147+
<span className="text-xs uppercase tracking-wider text-[var(--color-muted)]">Headline</span>
148+
<input className="input mt-1" value={current.headline} maxLength={80} onChange={(e) => patchActive({ headline: e.target.value })} />
149+
</label>
150+
<label className="block">
151+
<span className="text-xs uppercase tracking-wider text-[var(--color-muted)]">CTA</span>
152+
<input className="input mt-1" value={current.ctaText} maxLength={24} onChange={(e) => patchActive({ ctaText: e.target.value })} />
153+
</label>
154+
<label className="block sm:col-span-2">
155+
<span className="text-xs uppercase tracking-wider text-[var(--color-muted)]">Body</span>
156+
<input className="input mt-1" value={current.body} maxLength={140} onChange={(e) => patchActive({ body: e.target.value })} />
157+
</label>
158+
</div>
159+
160+
<div className="flex flex-wrap items-center gap-5">
161+
<ColorField label="Background" value={current.bgColor} onChange={(v) => patchAll({ bgColor: v })} />
162+
<ColorField label="Text" value={current.fgColor} onChange={(v) => patchAll({ fgColor: v })} />
163+
<ColorField label="Accent" value={current.accentColor} onChange={(v) => patchAll({ accentColor: v })} />
164+
<label className="btn cursor-pointer text-sm">
165+
{uploading ? "Uploading…" : current.logoUrl ? "Replace logo" : "Upload logo"}
166+
<input type="file" accept="image/png,image/jpeg,image/webp,image/svg+xml" className="hidden" onChange={onUploadLogo} disabled={uploading} />
167+
</label>
168+
</div>
169+
</div>
170+
)}
171+
172+
<div className="flex justify-end">
173+
<button className="btn btn-primary" onClick={save} disabled={saving}>
174+
{saving ? "Saving…" : "Save changes"}
175+
</button>
176+
</div>
177+
</div>
178+
);
179+
}
180+
181+
function ColorField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
182+
return (
183+
<label className="flex items-center gap-2">
184+
<input type="color" value={value} onChange={(e) => onChange(e.target.value)} className="h-8 w-8 cursor-pointer rounded border border-[var(--color-border)] bg-transparent" aria-label={label} />
185+
<span className="text-xs text-[var(--color-muted)]">
186+
{label}
187+
<br />
188+
<span className="font-mono">{value}</span>
189+
</span>
190+
</label>
191+
);
192+
}

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

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import Link from "next/link";
2+
import { notFound } from "next/navigation";
3+
import { createClient } from "@/lib/supabase/server";
4+
import type { AdCreative, AdFormatId } from "@/lib/ads/creative";
5+
import { EditCampaignForm } from "./edit-form";
6+
7+
export const metadata = { title: "Edit campaign" };
8+
9+
type CreativeRow = {
10+
id: string;
11+
format: AdFormatId;
12+
headline: string;
13+
body: string;
14+
cta_text: string;
15+
image_url: string | null;
16+
logo_url: string | null;
17+
bg_color: string;
18+
fg_color: string;
19+
accent_color: string;
20+
font_family: string;
21+
};
22+
23+
export default async function EditCampaignPage({
24+
params,
25+
}: {
26+
params: Promise<{ id: string }>;
27+
}) {
28+
const { id } = await params;
29+
const supabase = await createClient();
30+
const {
31+
data: { user },
32+
} = await supabase.auth.getUser();
33+
if (!user) notFound();
34+
35+
const { data: campaign } = await supabase
36+
.from("ad_campaigns")
37+
.select("id, name, destination_url, daily_budget_cents, bid_credits, status, ref_slug")
38+
.eq("id", id)
39+
.eq("owner_id", user.id)
40+
.maybeSingle();
41+
if (!campaign) notFound();
42+
43+
const { data: creativeRows } = await supabase
44+
.from("ad_creatives")
45+
.select("id, format, headline, body, cta_text, image_url, logo_url, bg_color, fg_color, accent_color, font_family")
46+
.eq("campaign_id", id)
47+
.order("format");
48+
49+
const creatives: (AdCreative & { id: string })[] = ((creativeRows as CreativeRow[]) ?? []).map(
50+
(r) => ({
51+
id: r.id,
52+
format: r.format,
53+
headline: r.headline,
54+
body: r.body,
55+
ctaText: r.cta_text,
56+
bgColor: r.bg_color,
57+
fgColor: r.fg_color,
58+
accentColor: r.accent_color,
59+
fontFamily: r.font_family,
60+
logoUrl: r.logo_url,
61+
imageUrl: r.image_url,
62+
}),
63+
);
64+
65+
return (
66+
<div className="mx-auto max-w-4xl">
67+
<Link href="/ads" className="text-sm text-[var(--color-muted)]">
68+
← Ad campaigns
69+
</Link>
70+
<div className="mt-4 flex items-center justify-between gap-3">
71+
<h1 className="text-3xl font-bold">Edit campaign</h1>
72+
<span className="badge font-mono">{campaign.ref_slug}</span>
73+
</div>
74+
<EditCampaignForm
75+
campaign={{
76+
id: campaign.id,
77+
name: campaign.name,
78+
destinationUrl: campaign.destination_url,
79+
dailyBudgetCents: campaign.daily_budget_cents,
80+
bidCredits: campaign.bid_credits ?? 4,
81+
status: campaign.status,
82+
}}
83+
creatives={creatives}
84+
/>
85+
</div>
86+
);
87+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { useState, useTransition, useRef } from "react";
44
import { useRouter } from "next/navigation";
55
import { previewAds, saveCampaign, uploadAdAsset } from "@/app/actions/ads";
6-
import { AD_FORMATS, type AdCreative, type AdFormatId } from "@/lib/ads/creative";
6+
import { AD_FORMATS, type AdCreative, type AdFormatId } from "@/lib/ads/formats";
77
import type { SiteBrand } from "@/lib/ads/brand";
88
import { AdPreview } from "@/components/ads/ad-preview";
99

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ export default async function AdsPage() {
7272
</div>
7373
<div className="flex items-center gap-3">
7474
<span className="badge whitespace-nowrap">{c.status}</span>
75+
<Link href={`/ads/${c.id}`} className="btn text-sm">
76+
Edit
77+
</Link>
7578
<CampaignActions id={c.id} status={c.status} />
7679
</div>
7780
</li>

‎app/actions/ads.ts‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,83 @@ export async function saveCampaign(input: {
169169
return { ok: true, id: campaign.data.id, refSlug: campaign.data.ref_slug };
170170
}
171171

172+
// --- Campaign editing (advertiser) ---
173+
174+
export async function updateCampaign(input: {
175+
id: string;
176+
name?: string;
177+
dailyBudgetCents?: number;
178+
bidCredits?: number;
179+
destinationUrl?: string;
180+
}): Promise<{ ok: true } | { ok: false; error: string }> {
181+
const supabase = await createClient();
182+
const {
183+
data: { user },
184+
} = await supabase.auth.getUser();
185+
if (!user) return { ok: false, error: "Not authenticated." };
186+
187+
const patch: Record<string, unknown> = {};
188+
if (typeof input.name === "string" && input.name.trim()) {
189+
patch.name = input.name.trim().slice(0, 120);
190+
}
191+
if (Number.isFinite(input.dailyBudgetCents)) {
192+
patch.daily_budget_cents = Math.max(0, Math.round(input.dailyBudgetCents!));
193+
}
194+
if (Number.isFinite(input.bidCredits)) {
195+
patch.bid_credits = Math.min(200, Math.max(1, Math.round(input.bidCredits!)));
196+
}
197+
if (typeof input.destinationUrl === "string" && input.destinationUrl.trim()) {
198+
const check = isAllowedTargetUrl(input.destinationUrl);
199+
if (!check.ok) return { ok: false, error: check.reason };
200+
patch.destination_url = check.url;
201+
patch.destination_domain = domainOf(check.url);
202+
}
203+
if (Object.keys(patch).length === 0) return { ok: true };
204+
205+
const { error } = await supabase
206+
.from("ad_campaigns")
207+
.update(patch)
208+
.eq("id", input.id)
209+
.eq("owner_id", user.id);
210+
if (error) return { ok: false, error: error.message };
211+
revalidatePath("/ads");
212+
revalidatePath(`/ads/${input.id}`);
213+
return { ok: true };
214+
}
215+
216+
export async function updateCreatives(input: {
217+
campaignId: string;
218+
creatives: (Partial<AdCreative> & { id: string })[];
219+
}): Promise<{ ok: true } | { ok: false; error: string }> {
220+
const supabase = await createClient();
221+
const {
222+
data: { user },
223+
} = await supabase.auth.getUser();
224+
if (!user) return { ok: false, error: "Not authenticated." };
225+
226+
for (const c of input.creatives) {
227+
const cleaned = cleanCreative(c);
228+
if (!cleaned) continue;
229+
const { error } = await supabase
230+
.from("ad_creatives")
231+
.update({
232+
headline: cleaned.headline,
233+
body: cleaned.body,
234+
cta_text: cleaned.ctaText,
235+
image_url: cleaned.imageUrl,
236+
logo_url: cleaned.logoUrl,
237+
bg_color: cleaned.bgColor,
238+
fg_color: cleaned.fgColor,
239+
accent_color: cleaned.accentColor,
240+
})
241+
.eq("id", c.id)
242+
.eq("owner_id", user.id);
243+
if (error) return { ok: false, error: error.message };
244+
}
245+
revalidatePath(`/ads/${input.campaignId}`);
246+
return { ok: true };
247+
}
248+
172249
// --- Campaign activation (advertiser) ---
173250

174251
const CAMPAIGN_STATUSES = new Set(["active", "paused", "draft"]);

‎components/ads/ad-preview.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client";
22

3-
import type { AdCreative } from "@/lib/ads/creative";
4-
import { formatSpec } from "@/lib/ads/creative";
3+
import type { AdCreative } from "@/lib/ads/formats";
4+
import { formatSpec } from "@/lib/ads/formats";
55

66
// Live React mirror of renderCreativeHtml (lib/ads/creative.ts). Kept visually
77
// in sync with the served HTML so the editor preview matches production.

0 commit comments

Comments
 (0)