Files
ClaudeMarketing/components/campaign-form.tsx
T
Trey t 66c2bbec8b feat: complete marketing command center with pipeline, UI, and asset generation
- Dashboard with campaign management, asset gallery, and publishing queue
- 7-agent pipeline: trend scout, research, scripts, ad creative, video, copy, distribution
- Campaign form with screenshot upload, goal picker, platform selection
- Campaign detail view with Details/Pipeline/Assets/Chat tabs
- Two-set image generation: Gemini AI (NanoBanana MCP) + Canvas Design posters
- Remotion video rendering with phone.png frame and real screenshot alignment
- honeyDue branding: blue #0079FF, orange #FF9400, Inter font, warm off-white
- Asset cards with source badges (Gemini/Canvas/Remotion/Playwright)
- Markdown/JSON render endpoint for viewing pipeline outputs as HTML
- Settings page with Tavily, Gemini, Postiz, Nextdoor integration management
- Claude Chat for campaign feedback loop with streaming SSE
- Postiz publishing modal with scheduling
- Auth with NextAuth credentials + JWT sessions
- SQLite via Prisma with better-sqlite3 adapter

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:05:26 -05:00

451 lines
15 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 } from "lucide-react";
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 (
<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</Label>
<Input
id="name"
name="name"
placeholder="Spring Launch Campaign"
defaultValue={initialData?.name || ""}
required
/>
</div>
<div className="space-y-2">
<Label>Platforms</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</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</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)</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</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</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</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)</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</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
</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>
);
}