Files
ClaudeMarketing/components/campaign-form.tsx
T
Trey t 807dfc539b feat: add asset preferences, video research, and Remotion ad assets
- Add thumbs-down feedback modal and preference API endpoint
- Add AI UGC video platforms research doc
- Add ReflectAd Remotion composition with public flow assets
- Add gemini-ad-designer and poster-ad-designer pipeline skills
- Add research_reflect_v1.1 pipeline script

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:28:07 -05:00

471 lines
17 KiB
TypeScript

"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { ImagePlus, X, Loader2, Info } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
function InfoTip({ text }: { text: string }) {
return (
<Tooltip>
<TooltipTrigger className="inline-flex text-muted-foreground hover:text-foreground transition-colors ml-1 align-middle">
<Info className="h-3.5 w-3.5" />
</TooltipTrigger>
<TooltipContent>{text}</TooltipContent>
</Tooltip>
);
}
export const PLATFORMS = ["instagram", "tiktok", "nextdoor"] as const;
export const GOALS = [
{ value: "app_downloads", label: "App Downloads", description: "Drive installs from App Store and Google Play" },
{ value: "brand_awareness", label: "Brand Awareness", description: "Maximize reach and impressions across platforms" },
{ value: "engagement", label: "Engagement", description: "Boost likes, comments, shares, and saves" },
] as const;
export interface CampaignData {
id?: string;
name: string;
platforms: string[];
config: {
goal: string;
keyMessage: string;
socialProof?: string;
targetAudience?: string;
visualDirection?: string;
competitorApps?: string;
variations?: number;
useTrendReport?: boolean;
screenshots?: string[];
};
}
interface CampaignFormProps {
initialData?: CampaignData;
mode?: "create" | "edit";
}
export function CampaignForm({ initialData, mode = "create" }: CampaignFormProps) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>(
initialData?.platforms || ["instagram"]
);
const [selectedGoal, setSelectedGoal] = useState(
initialData?.config.goal || "app_downloads"
);
const [screenshots, setScreenshots] = useState<
{ file?: File; preview: string; uploadedPath?: string }[]
>(() => {
// Pre-populate from existing campaign screenshots
const existing = initialData?.config.screenshots || [];
return existing.map((p) => ({
preview: `/api/files/${p}`,
uploadedPath: p,
}));
});
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
function handleFilesSelected(files: FileList | null) {
if (!files) return;
const newScreenshots = Array.from(files)
.filter((f) => f.type.startsWith("image/"))
.map((file) => ({
file,
preview: URL.createObjectURL(file),
}));
setScreenshots((prev) => [...prev, ...newScreenshots]);
}
function removeScreenshot(index: number) {
setScreenshots((prev) => {
const next = [...prev];
if (next[index].preview.startsWith("blob:")) {
URL.revokeObjectURL(next[index].preview);
}
next.splice(index, 1);
return next;
});
}
async function uploadScreenshots(): Promise<string[]> {
if (screenshots.length === 0) return [];
const needUpload = screenshots.filter((s) => !s.uploadedPath && s.file);
if (needUpload.length === 0) {
return screenshots.filter((s) => s.uploadedPath).map((s) => s.uploadedPath!);
}
setUploading(true);
const formData = new FormData();
for (const s of needUpload) {
formData.append("files", s.file!);
}
const res = await fetch("/api/uploads", {
method: "POST",
body: formData,
});
if (!res.ok) {
setUploading(false);
throw new Error("Failed to upload screenshots");
}
const data = await res.json();
const uploadedPaths: string[] = data.uploaded.map(
(u: { path: string }) => u.path
);
// Map uploaded paths back to screenshots
let uploadIdx = 0;
setScreenshots((prev) =>
prev.map((s) => {
if (!s.uploadedPath) {
return { ...s, uploadedPath: uploadedPaths[uploadIdx++] };
}
return s;
})
);
setUploading(false);
// Return all paths (previously uploaded + newly uploaded)
const allPaths: string[] = [];
let newIdx = 0;
for (const s of screenshots) {
if (s.uploadedPath) {
allPaths.push(s.uploadedPath);
} else {
allPaths.push(uploadedPaths[newIdx++]);
}
}
return allPaths;
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError("");
if (selectedPlatforms.length === 0) {
setError("Select at least one platform.");
return;
}
setLoading(true);
let screenshotPaths: string[] = [];
try {
screenshotPaths = await uploadScreenshots();
} catch {
setError("Failed to upload screenshots. Please try again.");
setLoading(false);
return;
}
const formData = new FormData(e.currentTarget);
const body = {
name: formData.get("name") as string,
platforms: selectedPlatforms,
config: {
goal: formData.get("goal") as string,
keyMessage: formData.get("keyMessage") as string,
socialProof: formData.get("socialProof") as string,
targetAudience: formData.get("targetAudience") as string,
visualDirection: formData.get("visualDirection") as string,
competitorApps: formData.get("competitorApps") as string,
variations: Number(formData.get("variations")) || 5,
useTrendReport: formData.get("useTrendReport") === "on",
screenshots: screenshotPaths,
},
};
const isEdit = mode === "edit" && initialData?.id;
const url = isEdit
? `/api/campaigns/${initialData.id}`
: "/api/campaigns";
const method = isEdit ? "PATCH" : "POST";
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
isEdit
? { name: body.name, platforms: JSON.stringify(body.platforms), config: JSON.stringify(body.config) }
: body
),
});
if (res.ok) {
if (isEdit) {
router.refresh();
} else {
const campaign = await res.json();
router.push(`/campaigns/${campaign.id}`);
}
} else {
const data = await res.json().catch(() => null);
setError(data?.error || "Failed to save campaign. Please try again.");
setLoading(false);
}
}
function togglePlatform(platform: string) {
setSelectedPlatforms((prev) =>
prev.includes(platform)
? prev.filter((p) => p !== platform)
: [...prev, platform]
);
}
return (
<TooltipProvider>
<Card>
<CardHeader>
<CardTitle>{mode === "edit" ? "Edit Campaign" : "New Campaign"}</CardTitle>
<CardDescription>
{mode === "edit"
? "Update campaign details before launching"
: "Configure your campaign and launch the AI pipeline"}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="name">Campaign Name <InfoTip text="A descriptive name for this campaign. Used to organize outputs and label generated assets." /></Label>
<Input
id="name"
name="name"
placeholder="Spring Launch Campaign"
defaultValue={initialData?.name || ""}
required
/>
</div>
<div className="space-y-2">
<Label>Platforms <InfoTip text="Which social platforms to generate content for. Each platform gets platform-specific ad dimensions, captions, and video styles." /></Label>
<div className="flex gap-3">
{PLATFORMS.map((platform) => (
<button
key={platform}
type="button"
onClick={() => togglePlatform(platform)}
className={`rounded-lg border px-4 py-2 text-sm font-medium transition-colors ${
selectedPlatforms.includes(platform)
? "border-primary bg-primary text-primary-foreground"
: "border-border hover:bg-muted"
}`}
>
{platform.charAt(0).toUpperCase() + platform.slice(1)}
</button>
))}
</div>
</div>
<div className="space-y-2">
<Label>Campaign Goal <InfoTip text="The primary objective shapes the tone, CTA, and creative direction of all generated content." /></Label>
<input type="hidden" name="goal" value={selectedGoal} />
<div className="grid gap-2">
{GOALS.map((goal) => (
<button
key={goal.value}
type="button"
onClick={() => setSelectedGoal(goal.value)}
className={`rounded-lg border px-4 py-3 text-left transition-colors ${
selectedGoal === goal.value
? "border-primary bg-primary/5"
: "border-border hover:bg-muted"
}`}
>
<div className="text-sm font-medium">{goal.label}</div>
<div className="text-xs text-muted-foreground">{goal.description}</div>
</button>
))}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="keyMessage">Key Message <InfoTip text="The core value proposition. This drives every headline, hook, and CTA the AI generates." /></Label>
<Textarea
id="keyMessage"
name="keyMessage"
placeholder="What problem does your app solve? What's the main value proposition?"
defaultValue={initialData?.config.keyMessage || ""}
rows={3}
required
/>
</div>
<div className="space-y-2">
<Label>App Screenshots (optional) <InfoTip text="Real app screenshots become reference images for Gemini. The AI places them in phone mockups and uses them as the hero visual in ads." /></Label>
<p className="text-xs text-muted-foreground">
Upload screenshots of the feature you want to showcase. These will
be incorporated into generated ads.
</p>
{screenshots.length > 0 && (
<div className="grid grid-cols-3 gap-2">
{screenshots.map((s, i) => (
<div key={i} className="relative group">
<img
src={s.preview}
alt={s.file?.name || "Screenshot"}
className="w-full aspect-[9/19.5] object-cover rounded-md border"
/>
<button
type="button"
onClick={() => removeScreenshot(i)}
className="absolute top-1 right-1 rounded-full bg-black/60 p-0.5 text-white opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="h-3.5 w-3.5" />
</button>
{s.uploadedPath && (
<div className="absolute bottom-1 left-1 rounded bg-green-600/80 px-1.5 py-0.5 text-[10px] text-white">
Uploaded
</div>
)}
</div>
))}
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => handleFilesSelected(e.target.files)}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => {
e.preventDefault();
e.currentTarget.classList.add("border-primary", "bg-primary/5");
}}
onDragLeave={(e) => {
e.currentTarget.classList.remove("border-primary", "bg-primary/5");
}}
onDrop={(e) => {
e.preventDefault();
e.currentTarget.classList.remove("border-primary", "bg-primary/5");
handleFilesSelected(e.dataTransfer.files);
}}
className="flex w-full items-center justify-center gap-2 rounded-lg border-2 border-dashed border-border px-4 py-6 text-sm text-muted-foreground hover:border-primary hover:bg-primary/5 transition-colors"
>
<ImagePlus className="h-5 w-5" />
<span>Click or drag screenshots here</span>
</button>
</div>
<div className="space-y-2">
<Label htmlFor="socialProof">Social Proof <InfoTip text="Stats, ratings, or testimonials that build trust. Appears in ad copy and video overlays." /></Label>
<Textarea
id="socialProof"
name="socialProof"
placeholder="50K+ downloads, 4.8 star rating, Featured in App Store"
defaultValue={initialData?.config.socialProof || ""}
rows={2}
/>
</div>
<div className="space-y-2">
<Label htmlFor="targetAudience">Target Audience <InfoTip text="Who are we talking to? Influences tone, pain points, and hooks. Be specific — age, interests, situation." /></Label>
<Textarea
id="targetAudience"
name="targetAudience"
placeholder="25-35 year old professionals, busy parents, college students..."
defaultValue={initialData?.config.targetAudience || ""}
rows={2}
/>
</div>
<div className="space-y-2">
<Label htmlFor="visualDirection">Visual Direction <InfoTip text="Sets the overall aesthetic for generated images and videos. Affects color treatment, layout style, and mood." /></Label>
<select
id="visualDirection"
name="visualDirection"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
defaultValue={initialData?.config.visualDirection || "clean"}
>
<option value="clean">Clean & Minimal</option>
<option value="bold">Bold & Vibrant</option>
<option value="premium">Premium & Dark</option>
<option value="warm">Warm & Friendly</option>
<option value="tech">Tech & Modern</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="competitorApps">Competitor Apps (optional) <InfoTip text="Apps you're competing with. The research agent analyzes their messaging to differentiate yours." /></Label>
<Input
id="competitorApps"
name="competitorApps"
placeholder="Todoist, Notion, TickTick..."
defaultValue={initialData?.config.competitorApps || ""}
/>
</div>
<div className="space-y-2">
<Label htmlFor="variations">Variations Per Platform <InfoTip text="Number of unique hook angles to generate per platform. More variations = more A/B testing options." /></Label>
<Input
id="variations"
name="variations"
type="number"
defaultValue={initialData?.config.variations ?? 5}
min={1}
max={20}
/>
</div>
<div className="flex items-center gap-2">
<input
id="useTrendReport"
name="useTrendReport"
type="checkbox"
className="h-4 w-4 rounded border-input"
defaultChecked={initialData?.config.useTrendReport || false}
/>
<Label htmlFor="useTrendReport" className="font-normal">
Use latest trend report for hook inspiration
<InfoTip text="When checked, the trend scout agent runs first and feeds current social media trends into the script writer for timely hooks." />
</Label>
</div>
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
<Button type="submit" className="w-full" disabled={loading}>
{loading
? mode === "edit" ? "Saving..." : "Creating..."
: mode === "edit" ? "Save Changes" : "Create Campaign"}
</Button>
</form>
</CardContent>
</Card>
</TooltipProvider>
);
}