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
76 changes: 58 additions & 18 deletions app/(app)/dashboard/ads/[id]/edit/edit-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { updateCampaign, updateCreatives, uploadAdAsset } from "@/app/actions/ads";
import { AD_FORMATS, type AdCreative, type AdFormatId } from "@/lib/ads/formats";
import { AD_FORMATS, paletteFor, type AdCreative, type AdFormatId } from "@/lib/ads/formats";
import { AdPreview } from "@/components/ads/ad-preview";
import { ColorField } from "@/components/ads/color-field";
import type { AdTheme } from "@/lib/ads/theme";

type Campaign = {
id: string;
Expand Down Expand Up @@ -33,9 +35,17 @@ export function EditCampaignForm({
const [bid, setBid] = useState((campaign.bidCredits * 5) / 100); // credits → $
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
// get whichever one matches their page, so both are editable here.
const [theme, setTheme] = useState<AdTheme>("dark");
const [uploading, setUploading] = useState(false);

const current = creatives.find((c) => c.format === active) ?? creatives[0];
// What the pickers show: the stored trio for this theme, or the derived one
// when the creative predates theme variants and has no light trio yet.
const palette = current
? paletteFor(current, theme)
: { bgColor: "#0b0d10", fgColor: "#e7e9ee", accentColor: "#6ee7b7" };

function patchActive(patch: Partial<AdCreative>) {
setCreatives((cs) => cs.map((c) => (c.format === active ? { ...c, ...patch } : c)));
Expand All @@ -44,6 +54,28 @@ export function EditCampaignForm({
setCreatives((cs) => cs.map((c) => ({ ...c, ...patch })));
}

// Colours apply to every format at once (one brand, five sizes), and write to
// whichever trio the theme switch has selected. Editing the light trio for
// the first time seeds it from the derived palette, so a publisher never sees
// two of three colours change.
function patchPalette(key: "bg" | "fg" | "accent", v: string) {
if (theme === "dark") {
const field = key === "bg" ? "bgColor" : key === "fg" ? "fgColor" : "accentColor";
return patchAll({ [field]: v } as Partial<AdCreative>);
}
setCreatives((cs) =>
cs.map((c) => {
const seed = paletteFor(c, "light");
return {
...c,
lightBgColor: key === "bg" ? v : (c.lightBgColor ?? seed.bgColor),
lightFgColor: key === "fg" ? v : (c.lightFgColor ?? seed.fgColor),
lightAccentColor: key === "accent" ? v : (c.lightAccentColor ?? seed.accentColor),
};
}),
);
}

function onUpload(kind: "logoUrl" | "imageUrl") {
return async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
Expand Down Expand Up @@ -137,7 +169,7 @@ export function EditCampaignForm({
style={{ maxWidth: f.w > 360 ? 360 : f.w + 16 }}
>
<div style={{ overflowX: "auto" }}>
<AdPreview creative={c} />
<AdPreview creative={c} theme={theme} />
</div>
</button>
);
Expand All @@ -159,10 +191,31 @@ export function EditCampaignForm({
</label>
</div>

<div className="flex flex-wrap items-center gap-3">
<span className="text-xs uppercase tracking-wider text-[var(--color-muted)]">Theme</span>
{(["dark", "light"] as AdTheme[]).map((t) => (
<button
key={t}
type="button"
onClick={() => setTheme(t)}
className={`rounded-md px-3 py-1 text-sm capitalize ${
theme === t
? "bg-[var(--color-accent)] text-black"
: "border border-[var(--color-border)] text-[var(--color-muted)]"
}`}
>
{t}
</button>
))}
<span className="text-xs text-[var(--color-muted)]">
Publishers are served whichever matches their page.
</span>
</div>

<div className="flex flex-wrap items-center gap-5">
<ColorField label="Background" value={current.bgColor} onChange={(v) => patchAll({ bgColor: v })} />
<ColorField label="Text" value={current.fgColor} onChange={(v) => patchAll({ fgColor: v })} />
<ColorField label="Accent" value={current.accentColor} onChange={(v) => patchAll({ accentColor: v })} />
<ColorField label="Background" value={palette.bgColor} onChange={(v) => patchPalette("bg", v)} />
<ColorField label="Text" value={palette.fgColor} onChange={(v) => patchPalette("fg", v)} alpha={false} />
<ColorField label="Accent" value={palette.accentColor} onChange={(v) => patchPalette("accent", v)} />
<label className="btn cursor-pointer text-sm">
{uploading ? "Uploading…" : current.logoUrl ? "Replace logo" : "Upload logo"}
<input type="file" accept="image/png,image/jpeg,image/webp,image/svg+xml" className="hidden" onChange={onUpload("logoUrl")} disabled={uploading} />
Expand Down Expand Up @@ -193,16 +246,3 @@ export function EditCampaignForm({
</div>
);
}

function ColorField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
return (
<label className="flex items-center gap-2">
<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} />
<span className="text-xs text-[var(--color-muted)]">
{label}
<br />
<span className="font-mono">{value}</span>
</span>
</label>
);
}
8 changes: 7 additions & 1 deletion app/(app)/dashboard/ads/[id]/edit/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ type CreativeRow = {
bg_color: string;
fg_color: string;
accent_color: string;
light_bg_color: string | null;
light_fg_color: string | null;
light_accent_color: string | null;
font_family: string;
};

Expand All @@ -42,7 +45,7 @@ export default async function EditCampaignPage({

const { data: creativeRows } = await supabase
.from("ad_creatives")
.select("id, format, headline, body, cta_text, image_url, logo_url, bg_color, fg_color, accent_color, font_family")
.select("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")
.eq("campaign_id", id)
.order("format");

Expand All @@ -56,6 +59,9 @@ export default async function EditCampaignPage({
bgColor: r.bg_color,
fgColor: r.fg_color,
accentColor: r.accent_color,
lightBgColor: r.light_bg_color,
lightFgColor: r.light_fg_color,
lightAccentColor: r.light_accent_color,
fontFamily: r.font_family,
logoUrl: r.logo_url,
imageUrl: r.image_url,
Expand Down
8 changes: 7 additions & 1 deletion app/(app)/dashboard/ads/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ type CreativeRow = {
bg_color: string;
fg_color: string;
accent_color: string;
light_bg_color: string | null;
light_fg_color: string | null;
light_accent_color: string | null;
font_family: string;
};

Expand Down Expand Up @@ -64,7 +67,7 @@ export default async function CampaignDetailPage({
supabase
.from("ad_creatives")
.select(
"id, format, headline, body, cta_text, image_url, logo_url, bg_color, fg_color, accent_color, font_family",
"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",
)
.eq("campaign_id", id)
.order("format"),
Expand Down Expand Up @@ -95,6 +98,9 @@ export default async function CampaignDetailPage({
bgColor: r.bg_color,
fgColor: r.fg_color,
accentColor: r.accent_color,
lightBgColor: r.light_bg_color,
lightFgColor: r.light_fg_color,
lightAccentColor: r.light_accent_color,
fontFamily: r.font_family,
logoUrl: r.logo_url,
imageUrl: r.image_url,
Expand Down
86 changes: 54 additions & 32 deletions app/(app)/dashboard/ads/new/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
import { useState, useTransition, useRef } from "react";
import { useRouter } from "next/navigation";
import { previewAds, saveCampaign, uploadAdAsset } from "@/app/actions/ads";
import { AD_FORMATS, type AdCreative, type AdFormatId } from "@/lib/ads/formats";
import { AD_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";

function dollars(cents: number): string {
return `$${(cents / 100).toFixed(2)}`;
Expand All @@ -27,8 +29,13 @@ export function NewAdForm() {
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
// Which polarity the previews show and the colour pickers edit.
const [theme, setTheme] = useState<AdTheme>("dark");

const current = creatives.find((c) => c.format === active) ?? creatives[0];
const palette = current
? paletteFor(current, theme)
: { bgColor: "#0b0d10", fgColor: "#e7e9ee", accentColor: "#6ee7b7" };

function generate(e: React.FormEvent) {
e.preventDefault();
Expand All @@ -54,6 +61,27 @@ export function NewAdForm() {
setCreatives((cs) => cs.map((c) => ({ ...c, ...patch })));
}

// Writes to whichever trio the theme switch has selected. Editing the light
// trio for the first time seeds the other two from the derived palette, so a
// publisher never sees two of three colours jump.
function patchPalette(key: "bg" | "fg" | "accent", v: string) {
if (theme === "dark") {
const field = key === "bg" ? "bgColor" : key === "fg" ? "fgColor" : "accentColor";
return patchAll({ [field]: v } as Partial<AdCreative>);
}
setCreatives((cs) =>
cs.map((c) => {
const seed = paletteFor(c, "light");
return {
...c,
lightBgColor: key === "bg" ? v : (c.lightBgColor ?? seed.bgColor),
lightFgColor: key === "fg" ? v : (c.lightFgColor ?? seed.fgColor),
lightAccentColor: key === "accent" ? v : (c.lightAccentColor ?? seed.accentColor),
};
}),
);
}

function onUpload(kind: "logoUrl" | "imageUrl") {
return async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
Expand Down Expand Up @@ -186,7 +214,7 @@ export function NewAdForm() {
style={{ maxWidth: f.w > 360 ? 360 : f.w + 16 }}
>
<div style={{ overflowX: "auto" }}>
<AdPreview creative={c} />
<AdPreview creative={c} theme={theme} />
</div>
<div className="text-xs text-[var(--color-muted)]">
{f.label} · {f.w}×{f.h}
Expand Down Expand Up @@ -238,10 +266,31 @@ export function NewAdForm() {
</label>
</div>

<div className="flex flex-wrap items-center gap-3">
<span className="text-xs uppercase tracking-wider text-[var(--color-muted)]">Theme</span>
{(["dark", "light"] as AdTheme[]).map((t) => (
<button
key={t}
type="button"
onClick={() => setTheme(t)}
className={`rounded-md px-3 py-1 text-sm capitalize ${
theme === t
? "bg-[var(--color-accent)] text-black"
: "border border-[var(--color-border)] text-[var(--color-muted)]"
}`}
>
{t}
</button>
))}
<span className="text-xs text-[var(--color-muted)]">
Publishers are served whichever matches their page.
</span>
</div>

<div className="flex flex-wrap items-center gap-5">
<ColorField label="Background" value={current.bgColor} onChange={(v) => patchAll({ bgColor: v })} />
<ColorField label="Text" value={current.fgColor} onChange={(v) => patchAll({ fgColor: v })} />
<ColorField label="Accent" value={current.accentColor} onChange={(v) => patchAll({ accentColor: v })} />
<ColorField label="Background" value={palette.bgColor} onChange={(v) => patchPalette("bg", v)} />
<ColorField label="Text" value={palette.fgColor} onChange={(v) => patchPalette("fg", v)} alpha={false} />
<ColorField label="Accent" value={palette.accentColor} onChange={(v) => patchPalette("accent", v)} />
<span className="text-xs text-[var(--color-muted)]">Colours apply to all formats</span>
</div>

Expand Down Expand Up @@ -310,30 +359,3 @@ export function NewAdForm() {
</div>
);
}

function ColorField({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (v: string) => void;
}) {
return (
<label className="flex items-center gap-2">
<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}
/>
<span className="text-xs text-[var(--color-muted)]">
{label}
<br />
<span className="font-mono">{value}</span>
</span>
</label>
);
}
19 changes: 18 additions & 1 deletion app/actions/ads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ export async function previewAds(input: { url: string }): Promise<
}

const ALLOWED_FORMATS = new Set<AdFormatId>(AD_FORMAT_IDS);
const HEX = /^#[0-9a-fA-F]{6}$/;
// 6- or 8-digit: the editor's opacity slider writes #rrggbbaa.
const HEX = /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;

function cleanCreative(c: Partial<AdCreative>): AdCreative | null {
if (!c.format || !ALLOWED_FORMATS.has(c.format)) return null;
Expand All @@ -125,6 +126,13 @@ function cleanCreative(c: Partial<AdCreative>): AdCreative | null {
bgColor: HEX.test(c.bgColor ?? "") ? c.bgColor! : "#0b0d10",
fgColor: HEX.test(c.fgColor ?? "") ? c.fgColor! : "#e7e9ee",
accentColor: HEX.test(c.accentColor ?? "") ? c.accentColor! : "#6ee7b7",
// Light trio is optional: null means "derive it at render time", which is
// what every creative predating theme variants does. An invalid value is
// dropped to null rather than defaulted, so a bad edit falls back to the
// derived palette instead of pinning a wrong colour.
lightBgColor: HEX.test(c.lightBgColor ?? "") ? c.lightBgColor! : null,
lightFgColor: HEX.test(c.lightFgColor ?? "") ? c.lightFgColor! : null,
lightAccentColor: HEX.test(c.lightAccentColor ?? "") ? c.lightAccentColor! : null,
fontFamily: (c.fontFamily ?? "system-ui, sans-serif").slice(0, 200),
logoUrl: c.logoUrl ?? null,
imageUrl: c.imageUrl ?? null,
Expand Down Expand Up @@ -233,6 +241,9 @@ export async function saveCampaign(input: {
bg_color: c.bgColor,
fg_color: c.fgColor,
accent_color: c.accentColor,
light_bg_color: c.lightBgColor ?? null,
light_fg_color: c.lightFgColor ?? null,
light_accent_color: c.lightAccentColor ?? null,
font_family: c.fontFamily,
}));
const { error: cErr } = await supabase.from("ad_creatives").insert(rows);
Expand Down Expand Up @@ -310,6 +321,9 @@ export async function updateCreatives(input: {
bg_color: cleaned.bgColor,
fg_color: cleaned.fgColor,
accent_color: cleaned.accentColor,
light_bg_color: cleaned.lightBgColor,
light_fg_color: cleaned.lightFgColor,
light_accent_color: cleaned.lightAccentColor,
})
.eq("id", c.id)
.eq("owner_id", user.id);
Expand Down Expand Up @@ -389,6 +403,9 @@ export async function regenerateCampaign(input: {
bg_color: c.bgColor,
fg_color: c.fgColor,
accent_color: c.accentColor,
light_bg_color: c.lightBgColor ?? null,
light_fg_color: c.lightFgColor ?? null,
light_accent_color: c.lightAccentColor ?? null,
font_family: c.fontFamily,
};
const existingId = idByFormat.get(c.format);
Expand Down
Loading
Loading