66c2bbec8b
- 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>
451 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|