feat: add multi-app support with app switcher, per-app branding, and filtered queries
Apps share the same backend, API keys, and publishing flow but each gets its own branding (name, colors, icon, app URL), knowledge files (brand identity, product info, platform guidelines), and campaigns. The pipeline dynamically writes _knowledge/ files and copies app assets before each run. - Add App model with slug, colors, appUrl, and knowledge markdown fields - Add appId FK to Campaign, seed honeyDue as first app with existing knowledge - App switcher dropdown in sidebar with icon previews - Filter campaigns, stats, and assets by active app (cookie-based) - De-hardcode lib/claude.ts: AppConfig interface, templated prompts, dynamic _knowledge/ and Remotion asset copying - App management pages (list, create, edit) with icon upload and color pickers - Asset library sort options (newest, oldest, name, platform, type) - Asset cards show creation date - Remotion HoneyDueAd accepts colors/appName props Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -63,3 +63,7 @@ pipeline/remotion-ad/public/icon.png
|
|||||||
# uploaded screenshots (user content)
|
# uploaded screenshots (user content)
|
||||||
pipeline/assets/screenshots/*.png
|
pipeline/assets/screenshots/*.png
|
||||||
!pipeline/assets/screenshots/.gitkeep
|
!pipeline/assets/screenshots/.gitkeep
|
||||||
|
|
||||||
|
# per-app assets and dynamic knowledge files
|
||||||
|
pipeline/apps/*/screenshots/*.png
|
||||||
|
pipeline/_knowledge/
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export default function LoginPage() {
|
|||||||
return (
|
return (
|
||||||
<Card className="w-full max-w-sm">
|
<Card className="w-full max-w-sm">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-2xl">honeyDue Marketing</CardTitle>
|
<CardTitle className="text-2xl">Marketing Command Center</CardTitle>
|
||||||
<CardDescription>Sign in to the command center</CardDescription>
|
<CardDescription>Sign in to the command center</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { AppForm } from "@/components/app-form";
|
||||||
|
|
||||||
|
export default function EditAppPage() {
|
||||||
|
const params = useParams<{ slug: string }>();
|
||||||
|
const [data, setData] = useState<{
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string;
|
||||||
|
appUrl: string;
|
||||||
|
primaryColor: string;
|
||||||
|
accentColor: string;
|
||||||
|
darkBg: string;
|
||||||
|
brandIdentity: string;
|
||||||
|
productInfo: string;
|
||||||
|
platformGuidelines: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`/api/apps/${params.slug}`)
|
||||||
|
.then((r) => {
|
||||||
|
if (!r.ok) throw new Error("Not found");
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then((app) => {
|
||||||
|
setData({
|
||||||
|
name: app.name,
|
||||||
|
slug: app.slug,
|
||||||
|
description: app.description || "",
|
||||||
|
appUrl: app.appUrl || "",
|
||||||
|
primaryColor: app.primaryColor,
|
||||||
|
accentColor: app.accentColor,
|
||||||
|
darkBg: app.darkBg,
|
||||||
|
brandIdentity: app.brandIdentity || "",
|
||||||
|
productInfo: app.productInfo || "",
|
||||||
|
platformGuidelines: app.platformGuidelines || "",
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setError("App not found");
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, [params.slug]);
|
||||||
|
|
||||||
|
if (loading) return <div className="text-muted-foreground">Loading...</div>;
|
||||||
|
if (error) return <div className="text-red-500">{error}</div>;
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl">
|
||||||
|
<AppForm mode="edit" initialData={data} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { AppForm } from "@/components/app-form";
|
||||||
|
|
||||||
|
export default function NewAppPage() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl">
|
||||||
|
<AppForm mode="create" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
|
||||||
|
interface AppItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string | null;
|
||||||
|
primaryColor: string;
|
||||||
|
accentColor: string;
|
||||||
|
createdAt: string;
|
||||||
|
_count: { campaigns: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppsPage() {
|
||||||
|
const [apps, setApps] = useState<AppItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/apps")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
setApps(data);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Apps</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Manage apps sharing this marketing pipeline
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button render={<Link href="/apps/new" />}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add App
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-muted-foreground">Loading...</div>
|
||||||
|
) : apps.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-10 text-center text-muted-foreground">
|
||||||
|
No apps yet. Create one to get started.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{apps.map((app) => (
|
||||||
|
<Link key={app.id} href={`/apps/${app.slug}`}>
|
||||||
|
<Card className="transition-shadow hover:shadow-md">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="h-8 w-8 rounded-lg"
|
||||||
|
style={{ backgroundColor: app.primaryColor }}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg">{app.name}</CardTitle>
|
||||||
|
<CardDescription>/{app.slug}</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="mb-3 text-sm text-muted-foreground line-clamp-2">
|
||||||
|
{app.description || "No description"}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-4 text-sm">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div
|
||||||
|
className="h-3 w-3 rounded"
|
||||||
|
style={{ backgroundColor: app.primaryColor }}
|
||||||
|
/>
|
||||||
|
<span>{app.primaryColor}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div
|
||||||
|
className="h-3 w-3 rounded"
|
||||||
|
style={{ backgroundColor: app.accentColor }}
|
||||||
|
/>
|
||||||
|
<span>{app.accentColor}</span>
|
||||||
|
</div>
|
||||||
|
<span className="ml-auto text-muted-foreground">
|
||||||
|
{app._count.campaigns} campaign{app._count.campaigns !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { writeFile, mkdir } from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const PIPELINE_ROOT = process.env.PIPELINE_ROOT || path.join(process.cwd(), "pipeline");
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) return new Response("Unauthorized", { status: 401 });
|
||||||
|
|
||||||
|
const { slug } = await params;
|
||||||
|
const app = await prisma.app.findUnique({ where: { slug } });
|
||||||
|
if (!app) return Response.json({ error: "App not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const type = formData.get("type") as string; // "icon", "phone", or "screenshot"
|
||||||
|
const file = formData.get("file") as File | null;
|
||||||
|
|
||||||
|
if (!file || !file.type.startsWith("image/")) {
|
||||||
|
return Response.json({ error: "No valid image file provided" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const appDir = path.join(PIPELINE_ROOT, "apps", slug);
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
|
||||||
|
if (type === "icon" || type === "phone") {
|
||||||
|
const filePath = path.join(appDir, `${type}.png`);
|
||||||
|
await mkdir(appDir, { recursive: true });
|
||||||
|
await writeFile(filePath, buffer);
|
||||||
|
return Response.json({ path: `apps/${slug}/${type}.png` });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "screenshot") {
|
||||||
|
const screenshotsDir = path.join(appDir, "screenshots");
|
||||||
|
await mkdir(screenshotsDir, { recursive: true });
|
||||||
|
const fileName = file.name || "screenshot.png";
|
||||||
|
const filePath = path.join(screenshotsDir, fileName);
|
||||||
|
await writeFile(filePath, buffer);
|
||||||
|
return Response.json({ path: `apps/${slug}/screenshots/${fileName}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ error: "Invalid type. Use: icon, phone, or screenshot" }, { status: 400 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) return new Response("Unauthorized", { status: 401 });
|
||||||
|
|
||||||
|
const { slug } = await params;
|
||||||
|
const app = await prisma.app.findUnique({
|
||||||
|
where: { slug },
|
||||||
|
include: { _count: { select: { campaigns: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!app) return Response.json({ error: "Not found" }, { status: 404 });
|
||||||
|
return Response.json(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) return new Response("Unauthorized", { status: 401 });
|
||||||
|
|
||||||
|
const { slug } = await params;
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
const app = await prisma.app.findUnique({ where: { slug } });
|
||||||
|
if (!app) return Response.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const updated = await prisma.app.update({
|
||||||
|
where: { slug },
|
||||||
|
data: {
|
||||||
|
name: body.name ?? undefined,
|
||||||
|
description: body.description ?? undefined,
|
||||||
|
appUrl: body.appUrl ?? undefined,
|
||||||
|
primaryColor: body.primaryColor ?? undefined,
|
||||||
|
accentColor: body.accentColor ?? undefined,
|
||||||
|
darkBg: body.darkBg ?? undefined,
|
||||||
|
brandIdentity: body.brandIdentity ?? undefined,
|
||||||
|
productInfo: body.productInfo ?? undefined,
|
||||||
|
platformGuidelines: body.platformGuidelines ?? undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) return new Response("Unauthorized", { status: 401 });
|
||||||
|
|
||||||
|
const { slug } = await params;
|
||||||
|
const app = await prisma.app.findUnique({ where: { slug } });
|
||||||
|
if (!app) return Response.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
// Check for campaigns
|
||||||
|
const campaignCount = await prisma.campaign.count({ where: { appId: app.id } });
|
||||||
|
if (campaignCount > 0) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: `Cannot delete app with ${campaignCount} campaign(s). Remove campaigns first.` },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.app.delete({ where: { slug } });
|
||||||
|
return Response.json({ deleted: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { mkdirSync } from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) return new Response("Unauthorized", { status: 401 });
|
||||||
|
|
||||||
|
const apps = await prisma.app.findMany({
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
description: true,
|
||||||
|
primaryColor: true,
|
||||||
|
accentColor: true,
|
||||||
|
darkBg: true,
|
||||||
|
createdAt: true,
|
||||||
|
_count: { select: { campaigns: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json(apps);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) return new Response("Unauthorized", { status: 401 });
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { name, slug, description, appUrl, primaryColor, accentColor, darkBg, brandIdentity, productInfo, platformGuidelines } = body;
|
||||||
|
|
||||||
|
if (!name || !slug) {
|
||||||
|
return Response.json({ error: "Name and slug are required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate slug format
|
||||||
|
if (!/^[a-z0-9-]+$/.test(slug)) {
|
||||||
|
return Response.json({ error: "Slug must be lowercase alphanumeric with hyphens only" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.app.findUnique({ where: { slug } });
|
||||||
|
if (existing) {
|
||||||
|
return Response.json({ error: "An app with this slug already exists" }, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await prisma.app.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
description: description || null,
|
||||||
|
appUrl: appUrl || null,
|
||||||
|
primaryColor: primaryColor || "#0079FF",
|
||||||
|
accentColor: accentColor || "#FF9400",
|
||||||
|
darkBg: darkBg || "#1a1a2e",
|
||||||
|
brandIdentity: brandIdentity || null,
|
||||||
|
productInfo: productInfo || null,
|
||||||
|
platformGuidelines: platformGuidelines || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create filesystem structure
|
||||||
|
const pipelineRoot = process.env.PIPELINE_ROOT || path.join(process.cwd(), "pipeline");
|
||||||
|
mkdirSync(path.join(pipelineRoot, "apps", slug, "screenshots"), { recursive: true });
|
||||||
|
|
||||||
|
return Response.json(app, { status: 201 });
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getActiveAppId } from "@/lib/active-app";
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
@@ -12,6 +13,8 @@ export async function GET(request: Request) {
|
|||||||
const status = searchParams.get("status");
|
const status = searchParams.get("status");
|
||||||
const search = searchParams.get("search");
|
const search = searchParams.get("search");
|
||||||
|
|
||||||
|
const appId = await getActiveAppId();
|
||||||
|
|
||||||
const where: Record<string, unknown> = {};
|
const where: Record<string, unknown> = {};
|
||||||
if (campaignId) where.campaignId = campaignId;
|
if (campaignId) where.campaignId = campaignId;
|
||||||
if (type && type !== "all") where.type = type;
|
if (type && type !== "all") where.type = type;
|
||||||
@@ -24,6 +27,11 @@ export async function GET(request: Request) {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter by active app's campaigns
|
||||||
|
if (appId) {
|
||||||
|
where.campaign = { ...((where.campaign as object) || {}), appId };
|
||||||
|
}
|
||||||
|
|
||||||
const assets = await prisma.asset.findMany({
|
const assets = await prisma.asset.findMany({
|
||||||
where,
|
where,
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { buildCampaignPrompt, launchPipeline } from "@/lib/claude";
|
import { buildCampaignPrompt, launchPipeline, type AppConfig } from "@/lib/claude";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { mkdirSync } from "fs";
|
import { mkdirSync } from "fs";
|
||||||
|
|
||||||
@@ -13,7 +13,10 @@ export async function POST(
|
|||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
const campaign = await prisma.campaign.findUnique({ where: { id } });
|
const campaign = await prisma.campaign.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { app: true },
|
||||||
|
});
|
||||||
if (!campaign) {
|
if (!campaign) {
|
||||||
return Response.json({ error: "Not found" }, { status: 404 });
|
return Response.json({ error: "Not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
@@ -25,6 +28,23 @@ export async function POST(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build AppConfig from the campaign's app
|
||||||
|
let appConfig: AppConfig | undefined;
|
||||||
|
if (campaign.app) {
|
||||||
|
const app = campaign.app;
|
||||||
|
appConfig = {
|
||||||
|
name: app.name,
|
||||||
|
slug: app.slug,
|
||||||
|
primaryColor: app.primaryColor,
|
||||||
|
accentColor: app.accentColor,
|
||||||
|
darkBg: app.darkBg,
|
||||||
|
assetsDir: `apps/${app.slug}`,
|
||||||
|
brandIdentity: app.brandIdentity,
|
||||||
|
productInfo: app.productInfo,
|
||||||
|
platformGuidelines: app.platformGuidelines,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const config = campaign.config ? JSON.parse(campaign.config) : {};
|
const config = campaign.config ? JSON.parse(campaign.config) : {};
|
||||||
const pipelineRoot =
|
const pipelineRoot =
|
||||||
process.env.PIPELINE_ROOT || path.join(process.cwd(), "pipeline");
|
process.env.PIPELINE_ROOT || path.join(process.cwd(), "pipeline");
|
||||||
@@ -39,7 +59,8 @@ export async function POST(
|
|||||||
mkdirSync(path.join(pipelineRoot, outputPath, dir), { recursive: true });
|
mkdirSync(path.join(pipelineRoot, outputPath, dir), { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const prompt = buildCampaignPrompt({
|
const prompt = buildCampaignPrompt(
|
||||||
|
{
|
||||||
name: campaign.name,
|
name: campaign.name,
|
||||||
platforms: JSON.parse(campaign.platforms),
|
platforms: JSON.parse(campaign.platforms),
|
||||||
goal: config.goal || "brand awareness",
|
goal: config.goal || "brand awareness",
|
||||||
@@ -51,7 +72,9 @@ export async function POST(
|
|||||||
variations: config.variations,
|
variations: config.variations,
|
||||||
useTrendReport: config.useTrendReport,
|
useTrendReport: config.useTrendReport,
|
||||||
screenshots: config.screenshots,
|
screenshots: config.screenshots,
|
||||||
});
|
},
|
||||||
|
appConfig
|
||||||
|
);
|
||||||
|
|
||||||
await prisma.campaign.update({
|
await prisma.campaign.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -59,7 +82,7 @@ export async function POST(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Launch pipeline asynchronously — don't await
|
// Launch pipeline asynchronously — don't await
|
||||||
launchPipeline(id, prompt, pipelineRoot).catch((err) =>
|
launchPipeline(id, prompt, pipelineRoot, appConfig).catch((err) =>
|
||||||
console.error(`Pipeline failed for campaign ${id}:`, err)
|
console.error(`Pipeline failed for campaign ${id}:`, err)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getActiveAppId } from "@/lib/active-app";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session) return new Response("Unauthorized", { status: 401 });
|
if (!session) return new Response("Unauthorized", { status: 401 });
|
||||||
|
|
||||||
|
const appId = await getActiveAppId();
|
||||||
|
|
||||||
const campaigns = await prisma.campaign.findMany({
|
const campaigns = await prisma.campaign.findMany({
|
||||||
|
where: appId ? { appId } : {},
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
include: {
|
include: {
|
||||||
_count: { select: { assets: true, agentRuns: true } },
|
_count: { select: { assets: true, agentRuns: true } },
|
||||||
@@ -29,11 +33,14 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const appId = await getActiveAppId();
|
||||||
|
|
||||||
const campaign = await prisma.campaign.create({
|
const campaign = await prisma.campaign.create({
|
||||||
data: {
|
data: {
|
||||||
name,
|
name,
|
||||||
platforms: JSON.stringify(platforms),
|
platforms: JSON.stringify(platforms),
|
||||||
config: config ? JSON.stringify(config) : null,
|
config: config ? JSON.stringify(config) : null,
|
||||||
|
appId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getActiveAppId } from "@/lib/active-app";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session) return new Response("Unauthorized", { status: 401 });
|
if (!session) return new Response("Unauthorized", { status: 401 });
|
||||||
|
|
||||||
|
const appId = await getActiveAppId();
|
||||||
const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const campaignWhere = appId ? { appId } : {};
|
||||||
|
const assetCampaignFilter = appId ? { campaign: { appId } } : {};
|
||||||
|
|
||||||
const [
|
const [
|
||||||
activeCampaigns,
|
activeCampaigns,
|
||||||
pendingReview,
|
pendingReview,
|
||||||
@@ -15,21 +20,23 @@ export async function GET() {
|
|||||||
trendReports,
|
trendReports,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
prisma.campaign.count({
|
prisma.campaign.count({
|
||||||
where: { status: { in: ["running", "review"] } },
|
where: { ...campaignWhere, status: { in: ["running", "review"] } },
|
||||||
}),
|
}),
|
||||||
prisma.asset.count({
|
prisma.asset.count({
|
||||||
where: {
|
where: {
|
||||||
status: "draft",
|
status: "draft",
|
||||||
campaign: { status: { in: ["review", "running"] } },
|
campaign: { ...campaignWhere, status: { in: ["review", "running"] } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.asset.count({
|
prisma.asset.count({
|
||||||
where: {
|
where: {
|
||||||
|
...assetCampaignFilter,
|
||||||
status: "published",
|
status: "published",
|
||||||
createdAt: { gte: oneWeekAgo },
|
createdAt: { gte: oneWeekAgo },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.campaign.findMany({
|
prisma.campaign.findMany({
|
||||||
|
where: campaignWhere,
|
||||||
take: 5,
|
take: 5,
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
select: { id: true, name: true, status: true, createdAt: true },
|
select: { id: true, name: true, status: true, createdAt: true },
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
|
import { getActiveApp } from "@/lib/active-app";
|
||||||
import { writeFile, mkdir } from "fs/promises";
|
import { writeFile, mkdir } from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
@@ -17,7 +18,15 @@ export async function POST(request: Request) {
|
|||||||
return Response.json({ error: "No files provided" }, { status: 400 });
|
return Response.json({ error: "No files provided" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const screenshotsDir = path.join(PIPELINE_ROOT, "assets", "screenshots");
|
// Determine upload path based on active app
|
||||||
|
const activeApp = await getActiveApp();
|
||||||
|
const screenshotsDir = activeApp
|
||||||
|
? path.join(PIPELINE_ROOT, "apps", activeApp.slug, "screenshots")
|
||||||
|
: path.join(PIPELINE_ROOT, "assets", "screenshots");
|
||||||
|
const relativeBase = activeApp
|
||||||
|
? `apps/${activeApp.slug}/screenshots`
|
||||||
|
: "assets/screenshots";
|
||||||
|
|
||||||
await mkdir(screenshotsDir, { recursive: true });
|
await mkdir(screenshotsDir, { recursive: true });
|
||||||
|
|
||||||
const uploaded: { fileName: string; path: string }[] = [];
|
const uploaded: { fileName: string; path: string }[] = [];
|
||||||
@@ -34,7 +43,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
uploaded.push({
|
uploaded.push({
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
path: `assets/screenshots/${uniqueName}`,
|
path: `${relativeBase}/${uniqueName}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -13,8 +13,8 @@ const jetbrainsMono = JetBrains_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "honeyDue — Marketing Command Center",
|
title: "Marketing Command Center",
|
||||||
description: "AI-powered marketing pipeline for honeyDue",
|
description: "AI-powered marketing pipeline",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { ActiveAppContext, type AppData } from "@/hooks/use-active-app";
|
||||||
|
|
||||||
|
function getCookie(name: string): string | null {
|
||||||
|
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
|
||||||
|
return match ? decodeURIComponent(match[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCookie(name: string, value: string, days = 365) {
|
||||||
|
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
||||||
|
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActiveAppProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [apps, setApps] = useState<AppData[]>([]);
|
||||||
|
const [activeApp, setActiveAppState] = useState<AppData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/apps")
|
||||||
|
.then((r) => {
|
||||||
|
if (!r.ok) return [];
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
if (!Array.isArray(data)) return;
|
||||||
|
setApps(data);
|
||||||
|
const savedSlug = getCookie("active-app");
|
||||||
|
const matched = data.find((a: AppData) => a.slug === savedSlug);
|
||||||
|
setActiveAppState(matched || data[0] || null);
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setActiveApp = useCallback(
|
||||||
|
(slug: string) => {
|
||||||
|
setCookie("active-app", slug);
|
||||||
|
const app = apps.find((a) => a.slug === slug);
|
||||||
|
if (app) setActiveAppState(app);
|
||||||
|
// Refresh the page to update server-side queries
|
||||||
|
window.location.reload();
|
||||||
|
},
|
||||||
|
[apps]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActiveAppContext value={{ apps, activeApp, setActiveApp, loading }}>
|
||||||
|
{children}
|
||||||
|
</ActiveAppContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Upload, ImageIcon } from "lucide-react";
|
||||||
|
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";
|
||||||
|
|
||||||
|
interface AppFormProps {
|
||||||
|
mode: "create" | "edit";
|
||||||
|
initialData?: {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string;
|
||||||
|
appUrl: string;
|
||||||
|
primaryColor: string;
|
||||||
|
accentColor: string;
|
||||||
|
darkBg: string;
|
||||||
|
brandIdentity: string;
|
||||||
|
productInfo: string;
|
||||||
|
platformGuidelines: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppForm({ mode, initialData }: AppFormProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const [name, setName] = useState(initialData?.name ?? "");
|
||||||
|
const [slug, setSlug] = useState(initialData?.slug ?? "");
|
||||||
|
const [description, setDescription] = useState(initialData?.description ?? "");
|
||||||
|
const [appUrl, setAppUrl] = useState(initialData?.appUrl ?? "");
|
||||||
|
const [primaryColor, setPrimaryColor] = useState(initialData?.primaryColor ?? "#0079FF");
|
||||||
|
const [accentColor, setAccentColor] = useState(initialData?.accentColor ?? "#FF9400");
|
||||||
|
const [darkBg, setDarkBg] = useState(initialData?.darkBg ?? "#1a1a2e");
|
||||||
|
const [brandIdentity, setBrandIdentity] = useState(initialData?.brandIdentity ?? "");
|
||||||
|
const [productInfo, setProductInfo] = useState(initialData?.productInfo ?? "");
|
||||||
|
const [platformGuidelines, setPlatformGuidelines] = useState(initialData?.platformGuidelines ?? "");
|
||||||
|
|
||||||
|
// Icon upload state
|
||||||
|
const iconInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [iconUploading, setIconUploading] = useState(false);
|
||||||
|
const [iconVersion, setIconVersion] = useState(0); // bust cache after upload
|
||||||
|
const [iconError, setIconError] = useState("");
|
||||||
|
|
||||||
|
const canUpload = mode === "edit" && !!initialData?.slug;
|
||||||
|
const iconSrc = canUpload
|
||||||
|
? `/api/files/apps/${initialData!.slug}/icon.png?v=${iconVersion}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
async function handleIconUpload(file: File) {
|
||||||
|
if (!canUpload) return;
|
||||||
|
setIconUploading(true);
|
||||||
|
setIconError("");
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
formData.append("type", "icon");
|
||||||
|
|
||||||
|
const res = await fetch(`/api/apps/${initialData!.slug}/assets`, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setIconError(data.error || "Upload failed");
|
||||||
|
} else {
|
||||||
|
setIconVersion((v) => v + 1);
|
||||||
|
}
|
||||||
|
setIconUploading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoSlug(value: string) {
|
||||||
|
return value
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
description,
|
||||||
|
appUrl: appUrl || null,
|
||||||
|
primaryColor,
|
||||||
|
accentColor,
|
||||||
|
darkBg,
|
||||||
|
brandIdentity: brandIdentity || null,
|
||||||
|
productInfo: productInfo || null,
|
||||||
|
platformGuidelines: platformGuidelines || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = mode === "create" ? "/api/apps" : `/api/apps/${initialData?.slug}`;
|
||||||
|
const method = mode === "create" ? "POST" : "PATCH";
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setError(data.error || "Something went wrong");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push("/apps");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{mode === "create" ? "Create App" : "Edit App"}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{mode === "create"
|
||||||
|
? "Add a new app to the marketing pipeline"
|
||||||
|
: "Update app details and branding"}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">App Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => {
|
||||||
|
setName(e.target.value);
|
||||||
|
if (mode === "create") setSlug(autoSlug(e.target.value));
|
||||||
|
}}
|
||||||
|
placeholder="My App"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="slug">Slug</Label>
|
||||||
|
<Input
|
||||||
|
id="slug"
|
||||||
|
value={slug}
|
||||||
|
onChange={(e) => setSlug(e.target.value)}
|
||||||
|
placeholder="my-app"
|
||||||
|
pattern="^[a-z0-9-]+$"
|
||||||
|
required
|
||||||
|
disabled={mode === "edit"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<Input
|
||||||
|
id="description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Brief description of the app"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="appUrl">App URL</Label>
|
||||||
|
<Input
|
||||||
|
id="appUrl"
|
||||||
|
type="url"
|
||||||
|
value={appUrl}
|
||||||
|
onChange={(e) => setAppUrl(e.target.value)}
|
||||||
|
placeholder="https://apps.apple.com/app/..."
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
App Store or website URL. Used for QR codes in ad creatives.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="primaryColor">Primary Color</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={primaryColor}
|
||||||
|
onChange={(e) => setPrimaryColor(e.target.value)}
|
||||||
|
className="h-9 w-12 cursor-pointer rounded border"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="primaryColor"
|
||||||
|
value={primaryColor}
|
||||||
|
onChange={(e) => setPrimaryColor(e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="accentColor">Accent Color</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={accentColor}
|
||||||
|
onChange={(e) => setAccentColor(e.target.value)}
|
||||||
|
className="h-9 w-12 cursor-pointer rounded border"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="accentColor"
|
||||||
|
value={accentColor}
|
||||||
|
onChange={(e) => setAccentColor(e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="darkBg">Dark Background</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={darkBg}
|
||||||
|
onChange={(e) => setDarkBg(e.target.value)}
|
||||||
|
className="h-9 w-12 cursor-pointer rounded border"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="darkBg"
|
||||||
|
value={darkBg}
|
||||||
|
onChange={(e) => setDarkBg(e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>App Icon</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{canUpload
|
||||||
|
? "Square icon PNG used in every ad creative."
|
||||||
|
: "Save the app first, then upload the icon from the edit page."}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-start gap-6">
|
||||||
|
{/* Icon preview */}
|
||||||
|
{iconSrc && (
|
||||||
|
<img
|
||||||
|
src={iconSrc}
|
||||||
|
alt={`${initialData?.name} icon`}
|
||||||
|
className="h-24 w-24 rounded-2xl border object-cover shadow-sm"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = "none";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!iconSrc && (
|
||||||
|
<div className="flex h-24 w-24 items-center justify-center rounded-2xl border border-dashed bg-muted">
|
||||||
|
<ImageIcon className="h-8 w-8 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Upload button */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<input
|
||||||
|
ref={iconInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) handleIconUpload(file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!canUpload || iconUploading}
|
||||||
|
onClick={() => iconInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<Upload className="mr-2 h-3.5 w-3.5" />
|
||||||
|
{iconUploading ? "Uploading..." : "Replace Icon"}
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Recommended: 1024x1024 PNG
|
||||||
|
</p>
|
||||||
|
{iconError && <p className="text-xs text-red-500">{iconError}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Knowledge Files</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Markdown content that agents read before generating content.
|
||||||
|
These replace the pipeline/knowledge/ files for this app.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="brandIdentity">Brand Identity</Label>
|
||||||
|
<Textarea
|
||||||
|
id="brandIdentity"
|
||||||
|
value={brandIdentity}
|
||||||
|
onChange={(e) => setBrandIdentity(e.target.value)}
|
||||||
|
placeholder="# Brand Identity ## Personality ..."
|
||||||
|
rows={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="productInfo">Product Info</Label>
|
||||||
|
<Textarea
|
||||||
|
id="productInfo"
|
||||||
|
value={productInfo}
|
||||||
|
onChange={(e) => setProductInfo(e.target.value)}
|
||||||
|
placeholder="# Product & Campaign Knowledge ## Overview ..."
|
||||||
|
rows={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="platformGuidelines">Platform Guidelines</Label>
|
||||||
|
<Textarea
|
||||||
|
id="platformGuidelines"
|
||||||
|
value={platformGuidelines}
|
||||||
|
onChange={(e) => setPlatformGuidelines(e.target.value)}
|
||||||
|
placeholder="# Platform Guidelines ## Instagram ..."
|
||||||
|
rows={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-500">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading
|
||||||
|
? mode === "create"
|
||||||
|
? "Creating..."
|
||||||
|
: "Saving..."
|
||||||
|
: mode === "create"
|
||||||
|
? "Create App"
|
||||||
|
: "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,10 @@ import {
|
|||||||
TrendingUp,
|
TrendingUp,
|
||||||
Calendar,
|
Calendar,
|
||||||
Settings,
|
Settings,
|
||||||
|
ChevronsUpDown,
|
||||||
|
Check,
|
||||||
|
Plus,
|
||||||
|
AppWindow,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
@@ -21,6 +25,14 @@ import {
|
|||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
SidebarHeader,
|
SidebarHeader,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { useActiveApp } from "@/hooks/use-active-app";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ title: "Dashboard", href: "/", icon: LayoutDashboard },
|
{ title: "Dashboard", href: "/", icon: LayoutDashboard },
|
||||||
@@ -28,21 +40,80 @@ const navItems = [
|
|||||||
{ title: "Assets", href: "/assets", icon: Image },
|
{ title: "Assets", href: "/assets", icon: Image },
|
||||||
{ title: "Trends", href: "/trends", icon: TrendingUp },
|
{ title: "Trends", href: "/trends", icon: TrendingUp },
|
||||||
{ title: "Queue", href: "/queue", icon: Calendar },
|
{ title: "Queue", href: "/queue", icon: Calendar },
|
||||||
|
{ title: "Apps", href: "/apps", icon: AppWindow },
|
||||||
{ title: "Settings", href: "/settings", icon: Settings },
|
{ title: "Settings", href: "/settings", icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const { apps, activeApp, setActiveApp } = useActiveApp();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar>
|
<Sidebar>
|
||||||
<SidebarHeader className="border-b px-6 py-4">
|
<SidebarHeader className="border-b px-6 py-4">
|
||||||
<Link href="/" className="flex items-center gap-2 font-semibold">
|
<DropdownMenu>
|
||||||
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-primary">
|
<DropdownMenuTrigger
|
||||||
<Megaphone className="h-4 w-4 text-primary-foreground" />
|
render={
|
||||||
|
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left font-semibold hover:bg-accent/50 focus:outline-none" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{activeApp ? (
|
||||||
|
<img
|
||||||
|
src={`/api/files/apps/${activeApp.slug}/icon.png`}
|
||||||
|
alt={activeApp.name}
|
||||||
|
className="h-7 w-7 rounded-md object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
const el = e.target as HTMLImageElement;
|
||||||
|
el.style.display = "none";
|
||||||
|
el.nextElementSibling?.classList.remove("hidden");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
className={`flex h-7 w-7 items-center justify-center rounded-md ${activeApp ? "hidden" : ""}`}
|
||||||
|
style={{ backgroundColor: activeApp?.primaryColor || "hsl(var(--primary))" }}
|
||||||
|
>
|
||||||
|
<Megaphone className="h-4 w-4 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span>honeyDue Marketing</span>
|
<span className="flex-1 truncate">
|
||||||
</Link>
|
{activeApp?.name || "Select App"}
|
||||||
|
</span>
|
||||||
|
<ChevronsUpDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent side="bottom" align="start" className="w-56">
|
||||||
|
{apps.map((app) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={app.slug}
|
||||||
|
onClick={() => setActiveApp(app.slug)}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={`/api/files/apps/${app.slug}/icon.png`}
|
||||||
|
alt={app.name}
|
||||||
|
className="h-5 w-5 rounded object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
const el = e.target as HTMLImageElement;
|
||||||
|
el.style.display = "none";
|
||||||
|
if (el.nextElementSibling) el.nextElementSibling.classList.remove("hidden");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="hidden h-5 w-5 rounded"
|
||||||
|
style={{ backgroundColor: app.primaryColor }}
|
||||||
|
/>
|
||||||
|
<span className="flex-1">{app.name}</span>
|
||||||
|
{activeApp?.slug === app.slug && (
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem render={<Link href="/apps/new" />} className="flex items-center gap-2">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
<span>Add App</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface Asset {
|
|||||||
dimensions?: string | null;
|
dimensions?: string | null;
|
||||||
metadata?: string | null;
|
metadata?: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
campaign?: { name: string };
|
campaign?: { name: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,11 +157,19 @@ export function AssetCard({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{asset.campaign && (
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<p className="text-xs text-muted-foreground">
|
{asset.campaign && <span>{asset.campaign.name}</span>}
|
||||||
{asset.campaign.name}
|
{asset.campaign && asset.createdAt && <span>·</span>}
|
||||||
</p>
|
{asset.createdAt && (
|
||||||
|
<span>
|
||||||
|
{new Date(asset.createdAt).toLocaleDateString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Actions — only for images and videos */}
|
{/* Actions — only for images and videos */}
|
||||||
{(isImage || isVideo) ? (
|
{(isImage || isVideo) ? (
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ interface Asset {
|
|||||||
dimensions?: string | null;
|
dimensions?: string | null;
|
||||||
metadata?: string | null;
|
metadata?: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
campaign?: { name: string };
|
campaign?: { name: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ export function AssetGallery({ campaignId, onPushToPostiz }: AssetGalleryProps)
|
|||||||
status: "all",
|
status: "all",
|
||||||
});
|
});
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const [sort, setSort] = useState("newest");
|
||||||
|
|
||||||
const fetchAssets = useCallback(() => {
|
const fetchAssets = useCallback(() => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -87,6 +89,24 @@ export function AssetGallery({ campaignId, onPushToPostiz }: AssetGalleryProps)
|
|||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sortedAssets = [...assets].sort((a, b) => {
|
||||||
|
switch (sort) {
|
||||||
|
case "oldest":
|
||||||
|
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||||
|
case "name-asc":
|
||||||
|
return a.fileName.localeCompare(b.fileName);
|
||||||
|
case "name-desc":
|
||||||
|
return b.fileName.localeCompare(a.fileName);
|
||||||
|
case "platform":
|
||||||
|
return (a.platform || "").localeCompare(b.platform || "");
|
||||||
|
case "type":
|
||||||
|
return a.type.localeCompare(b.type);
|
||||||
|
case "newest":
|
||||||
|
default:
|
||||||
|
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
@@ -132,6 +152,19 @@ export function AssetGallery({ campaignId, onPushToPostiz }: AssetGalleryProps)
|
|||||||
<option value="published">Published</option>
|
<option value="published">Published</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
className="h-9 rounded-md border px-3 text-sm"
|
||||||
|
value={sort}
|
||||||
|
onChange={(e) => setSort(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="newest">Newest First</option>
|
||||||
|
<option value="oldest">Oldest First</option>
|
||||||
|
<option value="name-asc">Name A–Z</option>
|
||||||
|
<option value="name-desc">Name Z–A</option>
|
||||||
|
<option value="platform">Platform</option>
|
||||||
|
<option value="type">Type</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search..."
|
placeholder="Search..."
|
||||||
value={search}
|
value={search}
|
||||||
@@ -168,13 +201,13 @@ export function AssetGallery({ campaignId, onPushToPostiz }: AssetGalleryProps)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Grid */}
|
{/* Grid */}
|
||||||
{assets.length === 0 ? (
|
{sortedAssets.length === 0 ? (
|
||||||
<p className="text-center text-muted-foreground py-12">
|
<p className="text-center text-muted-foreground py-12">
|
||||||
No assets yet. Launch a pipeline to generate content.
|
No assets yet. Launch a pipeline to generate content.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
<div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||||
{assets.map((asset) => (
|
{sortedAssets.map((asset) => (
|
||||||
<AssetCard
|
<AssetCard
|
||||||
key={asset.id}
|
key={asset.id}
|
||||||
asset={asset}
|
asset={asset}
|
||||||
|
|||||||
@@ -2,11 +2,14 @@
|
|||||||
|
|
||||||
import { SessionProvider } from "next-auth/react";
|
import { SessionProvider } from "next-auth/react";
|
||||||
import { SidebarProvider } from "@/components/ui/sidebar";
|
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||||
|
import { ActiveAppProvider } from "@/components/active-app-provider";
|
||||||
|
|
||||||
export function Providers({ children }: { children: React.ReactNode }) {
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
|
<ActiveAppProvider>
|
||||||
<SidebarProvider>{children}</SidebarProvider>
|
<SidebarProvider>{children}</SidebarProvider>
|
||||||
|
</ActiveAppProvider>
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
|
export interface AppData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string | null;
|
||||||
|
primaryColor: string;
|
||||||
|
accentColor: string;
|
||||||
|
darkBg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActiveAppContextValue {
|
||||||
|
apps: AppData[];
|
||||||
|
activeApp: AppData | null;
|
||||||
|
setActiveApp: (slug: string) => void;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ActiveAppContext = createContext<ActiveAppContextValue>({
|
||||||
|
apps: [],
|
||||||
|
activeApp: null,
|
||||||
|
setActiveApp: () => {},
|
||||||
|
loading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useActiveApp() {
|
||||||
|
return useContext(ActiveAppContext);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { prisma } from "./prisma";
|
||||||
|
import type { App } from "./generated/prisma/client";
|
||||||
|
|
||||||
|
const COOKIE_NAME = "active-app";
|
||||||
|
|
||||||
|
export async function getActiveApp(): Promise<App | null> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const slug = cookieStore.get(COOKIE_NAME)?.value;
|
||||||
|
|
||||||
|
if (slug) {
|
||||||
|
const app = await prisma.app.findUnique({ where: { slug } });
|
||||||
|
if (app) return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to first app
|
||||||
|
return prisma.app.findFirst({ orderBy: { createdAt: "asc" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActiveAppId(): Promise<string | null> {
|
||||||
|
const app = await getActiveApp();
|
||||||
|
return app?.id ?? null;
|
||||||
|
}
|
||||||
+108
-34
@@ -1,5 +1,5 @@
|
|||||||
import { spawn } from "child_process";
|
import { spawn } from "child_process";
|
||||||
import { mkdirSync } from "fs";
|
import { mkdirSync, writeFileSync, copyFileSync, existsSync } from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { EventEmitter } from "events";
|
import { EventEmitter } from "events";
|
||||||
import { prisma } from "./prisma";
|
import { prisma } from "./prisma";
|
||||||
@@ -21,6 +21,18 @@ const AGENT_STEPS = [
|
|||||||
|
|
||||||
export type AgentName = (typeof AGENT_STEPS)[number];
|
export type AgentName = (typeof AGENT_STEPS)[number];
|
||||||
|
|
||||||
|
export interface AppConfig {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
primaryColor: string;
|
||||||
|
accentColor: string;
|
||||||
|
darkBg: string;
|
||||||
|
assetsDir: string;
|
||||||
|
brandIdentity: string | null;
|
||||||
|
productInfo: string | null;
|
||||||
|
platformGuidelines: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface CampaignConfig {
|
interface CampaignConfig {
|
||||||
name: string;
|
name: string;
|
||||||
platforms: string[];
|
platforms: string[];
|
||||||
@@ -38,15 +50,23 @@ interface CampaignConfig {
|
|||||||
/**
|
/**
|
||||||
* Build a detailed campaign prompt that gives each agent enough context.
|
* Build a detailed campaign prompt that gives each agent enough context.
|
||||||
*/
|
*/
|
||||||
export function buildCampaignPrompt(config: CampaignConfig): string {
|
export function buildCampaignPrompt(config: CampaignConfig, appConfig?: AppConfig): string {
|
||||||
const platforms = config.platforms.join(", ");
|
const platforms = config.platforms.join(", ");
|
||||||
const variations = config.variations ?? 5;
|
const variations = config.variations ?? 5;
|
||||||
const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
||||||
const taskName = config.name.replace(/\s+/g, "_").toLowerCase();
|
const taskName = config.name.replace(/\s+/g, "_").toLowerCase();
|
||||||
const outputDir = `outputs/${taskName}_${dateStr}`;
|
const outputDir = `outputs/${taskName}_${dateStr}`;
|
||||||
|
|
||||||
|
const appName = appConfig?.name ?? "honeyDue";
|
||||||
|
const primaryColor = appConfig?.primaryColor ?? "#0079FF";
|
||||||
|
const accentColor = appConfig?.accentColor ?? "#FF9400";
|
||||||
|
const knowledgeDir = "_knowledge";
|
||||||
|
|
||||||
return `# Campaign Brief: "${config.name}"
|
return `# Campaign Brief: "${config.name}"
|
||||||
|
|
||||||
|
## App
|
||||||
|
${appName}
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
${config.goal === "app_downloads" ? "Drive app downloads" : config.goal === "brand_awareness" ? "Build brand awareness" : "Maximize engagement"}
|
${config.goal === "app_downloads" ? "Drive app downloads" : config.goal === "brand_awareness" ? "Build brand awareness" : "Maximize engagement"}
|
||||||
|
|
||||||
@@ -62,9 +82,13 @@ ${config.visualDirection ? `## Visual Direction\n${config.visualDirection}` : ""
|
|||||||
${config.competitorApps ? `## Competitor Context\n${config.competitorApps}` : ""}
|
${config.competitorApps ? `## Competitor Context\n${config.competitorApps}` : ""}
|
||||||
${config.screenshots?.length ? `## App Screenshots\nThe user provided ${config.screenshots.length} app screenshot(s) to showcase in the ads. These are real screenshots of the app feature being promoted.\nScreenshot files:\n${config.screenshots.map((p) => `- ${p}`).join("\n")}\n\nIMPORTANT: The ad-creative-designer agent MUST incorporate these screenshots into the static ad layouts. Use them as the hero visual — frame them in a device mockup or place them prominently in the ad composition. The screenshots show the actual feature being advertised, so they should be the centerpiece of the ad creative, not the AI-generated imagery.` : ""}
|
${config.screenshots?.length ? `## App Screenshots\nThe user provided ${config.screenshots.length} app screenshot(s) to showcase in the ads. These are real screenshots of the app feature being promoted.\nScreenshot files:\n${config.screenshots.map((p) => `- ${p}`).join("\n")}\n\nIMPORTANT: The ad-creative-designer agent MUST incorporate these screenshots into the static ad layouts. Use them as the hero visual — frame them in a device mockup or place them prominently in the ad composition. The screenshots show the actual feature being advertised, so they should be the centerpiece of the ad creative, not the AI-generated imagery.` : ""}
|
||||||
|
|
||||||
|
## Brand Colors
|
||||||
|
- Primary: ${primaryColor}
|
||||||
|
- Accent: ${accentColor}
|
||||||
|
|
||||||
## Content Requirements
|
## Content Requirements
|
||||||
- ${variations} hook variations per platform
|
- ${variations} hook variations per platform
|
||||||
- Static ads at exact platform dimensions (see knowledge/platform_guidelines.md)
|
- Static ads at exact platform dimensions (see ${knowledgeDir}/platform_guidelines.md)
|
||||||
- Video ads with platform-appropriate styles:
|
- Video ads with platform-appropriate styles:
|
||||||
- Instagram: "polished" style — clean motion graphics
|
- Instagram: "polished" style — clean motion graphics
|
||||||
- TikTok: "authentic" style — raw, native feel
|
- TikTok: "authentic" style — raw, native feel
|
||||||
@@ -86,7 +110,7 @@ ${config.useTrendReport ? "## Use Latest Trends\nCheck outputs/ for the most rec
|
|||||||
|
|
||||||
## Instructions
|
## Instructions
|
||||||
Read CLAUDE.md first. Then execute each agent skill in order:
|
Read CLAUDE.md first. Then execute each agent skill in order:
|
||||||
1. Read knowledge files (brand_identity.md, platform_guidelines.md, product_campaign.md)
|
1. Read knowledge files (${knowledgeDir}/brand_identity.md, ${knowledgeDir}/platform_guidelines.md, ${knowledgeDir}/product_campaign.md)
|
||||||
2. Run trend-scout skill
|
2. Run trend-scout skill
|
||||||
3. Run marketing-research-agent skill
|
3. Run marketing-research-agent skill
|
||||||
4. Run script-writer skill
|
4. Run script-writer skill
|
||||||
@@ -105,17 +129,24 @@ function buildAgentPrompt(
|
|||||||
agentName: string,
|
agentName: string,
|
||||||
campaignBrief: string,
|
campaignBrief: string,
|
||||||
outputDir: string,
|
outputDir: string,
|
||||||
|
appConfig?: AppConfig,
|
||||||
screenshots?: string[]
|
screenshots?: string[]
|
||||||
): string {
|
): string {
|
||||||
|
const appName = appConfig?.name ?? "honeyDue";
|
||||||
|
const primaryColor = appConfig?.primaryColor ?? "#0079FF";
|
||||||
|
const accentColor = appConfig?.accentColor ?? "#FF9400";
|
||||||
|
const assetsDir = appConfig?.assetsDir ?? "assets";
|
||||||
|
const knowledgeDir = "_knowledge";
|
||||||
|
|
||||||
const agentInstructions: Record<string, string> = {
|
const agentInstructions: Record<string, string> = {
|
||||||
"trend-scout": `You are the Trend Scout agent.
|
"trend-scout": `You are the Trend Scout agent.
|
||||||
|
|
||||||
Read and follow the skill instructions in skills/trend-scout/SKILL.md exactly.
|
Read and follow the skill instructions in skills/trend-scout/SKILL.md exactly.
|
||||||
|
|
||||||
First, read these knowledge files:
|
First, read these knowledge files:
|
||||||
- knowledge/brand_identity.md
|
- ${knowledgeDir}/brand_identity.md
|
||||||
- knowledge/platform_guidelines.md
|
- ${knowledgeDir}/platform_guidelines.md
|
||||||
- knowledge/product_campaign.md
|
- ${knowledgeDir}/product_campaign.md
|
||||||
|
|
||||||
Then execute the Tavily research queries described in the skill.
|
Then execute the Tavily research queries described in the skill.
|
||||||
Use the Bash tool to run: npx tavily search "your query here" OR write a Node.js script using @tavily/core.
|
Use the Bash tool to run: npx tavily search "your query here" OR write a Node.js script using @tavily/core.
|
||||||
@@ -129,9 +160,9 @@ ${campaignBrief}`,
|
|||||||
Read and follow the skill instructions in skills/marketing-research-agent/SKILL.md exactly.
|
Read and follow the skill instructions in skills/marketing-research-agent/SKILL.md exactly.
|
||||||
|
|
||||||
First, read these knowledge files:
|
First, read these knowledge files:
|
||||||
- knowledge/brand_identity.md
|
- ${knowledgeDir}/brand_identity.md
|
||||||
- knowledge/platform_guidelines.md
|
- ${knowledgeDir}/platform_guidelines.md
|
||||||
- knowledge/product_campaign.md
|
- ${knowledgeDir}/product_campaign.md
|
||||||
|
|
||||||
Read the trend report from: ${outputDir}/trend_report.json (if it exists)
|
Read the trend report from: ${outputDir}/trend_report.json (if it exists)
|
||||||
|
|
||||||
@@ -149,9 +180,9 @@ ${campaignBrief}`,
|
|||||||
Read and follow the skill instructions in skills/script-writer/SKILL.md exactly.
|
Read and follow the skill instructions in skills/script-writer/SKILL.md exactly.
|
||||||
|
|
||||||
First, read these knowledge files:
|
First, read these knowledge files:
|
||||||
- knowledge/brand_identity.md
|
- ${knowledgeDir}/brand_identity.md
|
||||||
- knowledge/platform_guidelines.md
|
- ${knowledgeDir}/platform_guidelines.md
|
||||||
- knowledge/product_campaign.md
|
- ${knowledgeDir}/product_campaign.md
|
||||||
|
|
||||||
Read the upstream outputs:
|
Read the upstream outputs:
|
||||||
- ${outputDir}/trend_report.json
|
- ${outputDir}/trend_report.json
|
||||||
@@ -172,9 +203,9 @@ ${campaignBrief}`,
|
|||||||
Read and follow the skill instructions in skills/ad-creative-designer/SKILL.md exactly.
|
Read and follow the skill instructions in skills/ad-creative-designer/SKILL.md exactly.
|
||||||
|
|
||||||
First, read these knowledge files:
|
First, read these knowledge files:
|
||||||
- knowledge/brand_identity.md
|
- ${knowledgeDir}/brand_identity.md
|
||||||
- knowledge/platform_guidelines.md
|
- ${knowledgeDir}/platform_guidelines.md
|
||||||
- knowledge/product_campaign.md
|
- ${knowledgeDir}/product_campaign.md
|
||||||
|
|
||||||
Read the upstream outputs:
|
Read the upstream outputs:
|
||||||
- ${outputDir}/scripts/scripts_all.json
|
- ${outputDir}/scripts/scripts_all.json
|
||||||
@@ -191,16 +222,16 @@ Use the NanoBanana MCP tools to create polished ad images.${screenshots?.length
|
|||||||
CRITICAL — REFERENCE IMAGES:
|
CRITICAL — REFERENCE IMAGES:
|
||||||
The user provided real app screenshots that MUST be used as reference images in every generate call.
|
The user provided real app screenshots that MUST be used as reference images in every generate call.
|
||||||
Screenshot absolute paths:
|
Screenshot absolute paths:
|
||||||
${screenshots.map((p) => `- \${CWD}/${p}`).join("\n")}
|
${screenshots.map((p) => `\${CWD}/${p}`).join("\n")}
|
||||||
|
|
||||||
Where \${CWD} is your current working directory (use pwd to get the absolute path).` : ""}
|
Where \${CWD} is your current working directory (use pwd to get the absolute path).` : ""}
|
||||||
|
|
||||||
APP ICON: The honeyDue app icon is at assets/icon.png (honeycomb pattern with golden checkmark on dark navy). This icon MUST be visible in every ad — include it as a reference_image alongside the screenshot, and instruct Gemini to place the honeyDue icon prominently near the branding/CTA area of every ad.
|
APP ICON: The ${appName} app icon is at ${assetsDir}/icon.png. This icon MUST be visible in every ad — include it as a reference_image alongside the screenshot, and instruct Gemini to place the ${appName} icon prominently near the branding/CTA area of every ad.
|
||||||
|
|
||||||
For EACH ad, follow this EXACT sequence:
|
For EACH ad, follow this EXACT sequence:
|
||||||
1. First call mcp__nanobanana__set_aspect_ratio to set the correct ratio for the platform (1:1 for feed, 9:16 for stories/reels/tiktok)
|
1. First call mcp__nanobanana__set_aspect_ratio to set the correct ratio for the platform (1:1 for feed, 9:16 for stories/reels/tiktok)
|
||||||
2. Then call mcp__nanobanana__gemini_generate_image with:
|
2. Then call mcp__nanobanana__gemini_generate_image with:
|
||||||
- prompt: A detailed description of the ad layout, headline text, brand colors (#0079FF blue, #FF9400 orange), and style${screenshots?.length ? `
|
- prompt: A detailed description of the ad layout, headline text, brand colors (${primaryColor} blue, ${accentColor} orange), and style${screenshots?.length ? `
|
||||||
- reference_images: ["/absolute/path/to/screenshot.png"] — use the REAL app screenshot so Gemini incorporates the actual UI, NOT a made-up version. The prompt should say "Use the provided reference image as the app screenshot shown on the phone screen in the ad. Do NOT change or recreate the app UI — use it exactly as provided."` : ""}
|
- reference_images: ["/absolute/path/to/screenshot.png"] — use the REAL app screenshot so Gemini incorporates the actual UI, NOT a made-up version. The prompt should say "Use the provided reference image as the app screenshot shown on the phone screen in the ad. Do NOT change or recreate the app UI — use it exactly as provided."` : ""}
|
||||||
- output_path: the destination file path
|
- output_path: the destination file path
|
||||||
3. Save to ${outputDir}/ads/gemini/
|
3. Save to ${outputDir}/ads/gemini/
|
||||||
@@ -230,8 +261,8 @@ The philosophy should:
|
|||||||
- Be 4-6 paragraphs articulating how the philosophy manifests through space, form, color, scale, rhythm, composition
|
- Be 4-6 paragraphs articulating how the philosophy manifests through space, form, color, scale, rhythm, composition
|
||||||
- Emphasize: visual expression over text, spatial communication, artistic interpretation, minimal words
|
- Emphasize: visual expression over text, spatial communication, artistic interpretation, minimal words
|
||||||
- Stress meticulous craftsmanship — the final work must appear as though someone at the top of their field labored over every detail
|
- Stress meticulous craftsmanship — the final work must appear as though someone at the top of their field labored over every detail
|
||||||
- Draw from the campaign's soul: home maintenance as an act of care, the quiet anxiety of forgetting, the relief of being organized
|
- Draw from the campaign's soul
|
||||||
- Brand palette as foundation: #0079FF blue, #FF9400 orange, warm off-white, dark navy
|
- Brand palette as foundation: ${primaryColor}, ${accentColor}, warm off-white, dark navy
|
||||||
|
|
||||||
### Step B: Express the Philosophy as Poster Art
|
### Step B: Express the Philosophy as Poster Art
|
||||||
Using the philosophy, create each poster as a .png file. For each:
|
Using the philosophy, create each poster as a .png file. For each:
|
||||||
@@ -240,10 +271,10 @@ Using the philosophy, create each poster as a .png file. For each:
|
|||||||
3. Use repeating patterns, perfect geometric shapes, systematic visual language
|
3. Use repeating patterns, perfect geometric shapes, systematic visual language
|
||||||
4. Typography is minimal and design-forward — sparse labels, bold single phrases, never paragraphs
|
4. Typography is minimal and design-forward — sparse labels, bold single phrases, never paragraphs
|
||||||
5. The campaign's hook text appears as a visual accent, not a headline block
|
5. The campaign's hook text appears as a visual accent, not a headline block
|
||||||
6. ${screenshots?.length ? `Incorporate the app screenshot (${screenshots.join(", ")}) inside the phone frame image at assets/phone.png — layer the screenshot BEHIND the phone frame PNG (which has a transparent screen area and an orange rim with dynamic island). The screenshot and phone frame dynamic islands must align. This creates a realistic device mockup. Treat this composite as part of the art, not just dropped in.` : "Use abstract visual representations of the app concept"}
|
6. ${screenshots?.length ? `Incorporate the app screenshot (${screenshots.join(", ")}) inside the phone frame image at ${assetsDir}/phone.png — layer the screenshot BEHIND the phone frame PNG (which has a transparent screen area and an orange rim with dynamic island). The screenshot and phone frame dynamic islands must align. This creates a realistic device mockup. Treat this composite as part of the art, not just dropped in.` : "Use abstract visual representations of the app concept"}
|
||||||
7. Every element contained within canvas boundaries with proper margins — nothing overlaps, everything breathes
|
7. Every element contained within canvas boundaries with proper margins — nothing overlaps, everything breathes
|
||||||
8. The result should look like it could hang in a gallery or appear in a design magazine
|
8. The result should look like it could hang in a gallery or appear in a design magazine
|
||||||
9. The honeyDue app icon (assets/icon.png — honeycomb with golden checkmark on dark navy) MUST appear in every poster, placed near the branding or CTA area. Use it as an <img> element in the HTML.
|
9. The ${appName} app icon (${assetsDir}/icon.png) MUST appear in every poster, placed near the branding or CTA area. Use it as an <img> element in the HTML.
|
||||||
|
|
||||||
### MANDATORY Typography & Sizing Rules (Social Media Readability)
|
### MANDATORY Typography & Sizing Rules (Social Media Readability)
|
||||||
These are viewed on phones at arm's length. Text that looks fine on a monitor is INVISIBLE in a feed.
|
These are viewed on phones at arm's length. Text that looks fine on a monitor is INVISIBLE in a feed.
|
||||||
@@ -288,9 +319,9 @@ ${campaignBrief}`,
|
|||||||
Read and follow the skill instructions in skills/video-ad-producer/SKILL.md exactly.
|
Read and follow the skill instructions in skills/video-ad-producer/SKILL.md exactly.
|
||||||
|
|
||||||
First, read these knowledge files:
|
First, read these knowledge files:
|
||||||
- knowledge/brand_identity.md
|
- ${knowledgeDir}/brand_identity.md
|
||||||
- knowledge/platform_guidelines.md
|
- ${knowledgeDir}/platform_guidelines.md
|
||||||
- knowledge/product_campaign.md
|
- ${knowledgeDir}/product_campaign.md
|
||||||
|
|
||||||
Read the upstream outputs:
|
Read the upstream outputs:
|
||||||
- ${outputDir}/scripts/scripts_all.json
|
- ${outputDir}/scripts/scripts_all.json
|
||||||
@@ -299,7 +330,7 @@ Read the upstream outputs:
|
|||||||
- ${outputDir}/ads/posters/manifest.json
|
- ${outputDir}/ads/posters/manifest.json
|
||||||
|
|
||||||
## Phone Frame Asset
|
## Phone Frame Asset
|
||||||
A real iPhone frame PNG is at: assets/phone.png (orange rim, dynamic island, transparent screen)
|
A real iPhone frame PNG is at: ${assetsDir}/phone.png (orange rim, dynamic island, transparent screen)
|
||||||
Also available in remotion-ad/public/phone.png for use as a Remotion staticFile.
|
Also available in remotion-ad/public/phone.png for use as a Remotion staticFile.
|
||||||
${screenshots?.length ? `
|
${screenshots?.length ? `
|
||||||
## App Screenshots
|
## App Screenshots
|
||||||
@@ -338,9 +369,9 @@ ${campaignBrief}`,
|
|||||||
Read and follow the skill instructions in skills/copywriter-agent/SKILL.md exactly.
|
Read and follow the skill instructions in skills/copywriter-agent/SKILL.md exactly.
|
||||||
|
|
||||||
First, read these knowledge files:
|
First, read these knowledge files:
|
||||||
- knowledge/brand_identity.md
|
- ${knowledgeDir}/brand_identity.md
|
||||||
- knowledge/platform_guidelines.md
|
- ${knowledgeDir}/platform_guidelines.md
|
||||||
- knowledge/product_campaign.md
|
- ${knowledgeDir}/product_campaign.md
|
||||||
|
|
||||||
Read the upstream outputs:
|
Read the upstream outputs:
|
||||||
- ${outputDir}/research_results.json
|
- ${outputDir}/research_results.json
|
||||||
@@ -362,8 +393,8 @@ ${campaignBrief}`,
|
|||||||
Read and follow the skill instructions in skills/distribution-agent/SKILL.md exactly.
|
Read and follow the skill instructions in skills/distribution-agent/SKILL.md exactly.
|
||||||
|
|
||||||
First, read these knowledge files:
|
First, read these knowledge files:
|
||||||
- knowledge/brand_identity.md
|
- ${knowledgeDir}/brand_identity.md
|
||||||
- knowledge/platform_guidelines.md
|
- ${knowledgeDir}/platform_guidelines.md
|
||||||
|
|
||||||
Gather ALL outputs from the campaign:
|
Gather ALL outputs from the campaign:
|
||||||
- ${outputDir}/ads/ (all PNG files)
|
- ${outputDir}/ads/ (all PNG files)
|
||||||
@@ -526,10 +557,47 @@ async function loadPipelineEnv(): Promise<Record<string, string>> {
|
|||||||
return env;
|
return env;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write the active app's knowledge files to pipeline/_knowledge/ for agents to read.
|
||||||
|
*/
|
||||||
|
function writeKnowledgeFiles(pipelineRoot: string, appConfig: AppConfig) {
|
||||||
|
const knowledgeDir = path.join(pipelineRoot, "_knowledge");
|
||||||
|
mkdirSync(knowledgeDir, { recursive: true });
|
||||||
|
|
||||||
|
if (appConfig.brandIdentity) {
|
||||||
|
writeFileSync(path.join(knowledgeDir, "brand_identity.md"), appConfig.brandIdentity, "utf-8");
|
||||||
|
}
|
||||||
|
if (appConfig.productInfo) {
|
||||||
|
writeFileSync(path.join(knowledgeDir, "product_campaign.md"), appConfig.productInfo, "utf-8");
|
||||||
|
}
|
||||||
|
if (appConfig.platformGuidelines) {
|
||||||
|
writeFileSync(path.join(knowledgeDir, "platform_guidelines.md"), appConfig.platformGuidelines, "utf-8");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy app's icon and phone frame to remotion-ad/public/ for video rendering.
|
||||||
|
*/
|
||||||
|
function copyAppAssetsToRemotion(pipelineRoot: string, appConfig: AppConfig) {
|
||||||
|
const remotionPublic = path.join(pipelineRoot, "remotion-ad", "public");
|
||||||
|
mkdirSync(remotionPublic, { recursive: true });
|
||||||
|
|
||||||
|
const iconSrc = path.join(pipelineRoot, appConfig.assetsDir, "icon.png");
|
||||||
|
const phoneSrc = path.join(pipelineRoot, appConfig.assetsDir, "phone.png");
|
||||||
|
|
||||||
|
if (existsSync(iconSrc)) {
|
||||||
|
copyFileSync(iconSrc, path.join(remotionPublic, "icon.png"));
|
||||||
|
}
|
||||||
|
if (existsSync(phoneSrc)) {
|
||||||
|
copyFileSync(phoneSrc, path.join(remotionPublic, "phone.png"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function launchPipeline(
|
export async function launchPipeline(
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
prompt: string,
|
prompt: string,
|
||||||
cwd: string
|
cwd: string,
|
||||||
|
appConfig?: AppConfig
|
||||||
) {
|
) {
|
||||||
// Load API keys from settings
|
// Load API keys from settings
|
||||||
const pipelineEnv = await loadPipelineEnv();
|
const pipelineEnv = await loadPipelineEnv();
|
||||||
@@ -540,6 +608,12 @@ export async function launchPipeline(
|
|||||||
const campaignConfig = campaignData?.config ? JSON.parse(campaignData.config) : {};
|
const campaignConfig = campaignData?.config ? JSON.parse(campaignData.config) : {};
|
||||||
const screenshots: string[] = campaignConfig.screenshots || [];
|
const screenshots: string[] = campaignConfig.screenshots || [];
|
||||||
|
|
||||||
|
// Write knowledge files and copy assets for the active app
|
||||||
|
if (appConfig) {
|
||||||
|
writeKnowledgeFiles(cwd, appConfig);
|
||||||
|
copyAppAssetsToRemotion(cwd, appConfig);
|
||||||
|
}
|
||||||
|
|
||||||
// Create output directories
|
// Create output directories
|
||||||
const dirs = ["ads", "scripts", "video", "copy"];
|
const dirs = ["ads", "scripts", "video", "copy"];
|
||||||
for (const dir of dirs) {
|
for (const dir of dirs) {
|
||||||
@@ -575,7 +649,7 @@ export async function launchPipeline(
|
|||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const agentPrompt = buildAgentPrompt(agentName, prompt, outputDir, screenshots);
|
const agentPrompt = buildAgentPrompt(agentName, prompt, outputDir, appConfig, screenshots);
|
||||||
const { output } = await runAgentStep(agentName, agentPrompt, cwd, pipelineEnv);
|
const { output } = await runAgentStep(agentName, agentPrompt, cwd, pipelineEnv);
|
||||||
const durationMs = Date.now() - startTime;
|
const durationMs = Date.now() - startTime;
|
||||||
|
|
||||||
|
|||||||
+5
-3
@@ -22,9 +22,11 @@ Seven agents running in sequence:
|
|||||||
|
|
||||||
# Knowledge Files (READ FIRST)
|
# Knowledge Files (READ FIRST)
|
||||||
Every agent MUST read these before generating ANY content:
|
Every agent MUST read these before generating ANY content:
|
||||||
- `knowledge/brand_identity.md` — tone, voice, personality, CTA patterns
|
- `_knowledge/brand_identity.md` — tone, voice, personality, CTA patterns
|
||||||
- `knowledge/platform_guidelines.md` — per-platform specs and formatting
|
- `_knowledge/platform_guidelines.md` — per-platform specs and formatting
|
||||||
- `knowledge/product_campaign.md` — product details, features, campaign direction
|
- `_knowledge/product_campaign.md` — product details, features, campaign direction
|
||||||
|
|
||||||
|
NOTE: The `_knowledge/` directory is dynamically written per-pipeline-run with the active app's content. The original `knowledge/` directory contains the honeyDue defaults.
|
||||||
|
|
||||||
# Available Tools
|
# Available Tools
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 329 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 521 KiB |
@@ -8,6 +8,15 @@ import {
|
|||||||
staticFile,
|
staticFile,
|
||||||
} from "remotion";
|
} from "remotion";
|
||||||
|
|
||||||
|
export interface AdColors {
|
||||||
|
primary?: string;
|
||||||
|
accent?: string;
|
||||||
|
dark?: string;
|
||||||
|
light?: string;
|
||||||
|
white?: string;
|
||||||
|
red?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface HoneyDueAdProps {
|
export interface HoneyDueAdProps {
|
||||||
platform: "instagram" | "tiktok";
|
platform: "instagram" | "tiktok";
|
||||||
hookText: string;
|
hookText: string;
|
||||||
@@ -15,9 +24,11 @@ export interface HoneyDueAdProps {
|
|||||||
ctaText: string;
|
ctaText: string;
|
||||||
proofText: string;
|
proofText: string;
|
||||||
screenshotSrc: string;
|
screenshotSrc: string;
|
||||||
|
colors?: AdColors;
|
||||||
|
appName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLORS = {
|
const DEFAULT_COLORS = {
|
||||||
primary: "#0079FF",
|
primary: "#0079FF",
|
||||||
accent: "#FF9400",
|
accent: "#FF9400",
|
||||||
dark: "#1a1a2e",
|
dark: "#1a1a2e",
|
||||||
@@ -33,7 +44,10 @@ export const HoneyDueAd: React.FC<HoneyDueAdProps> = ({
|
|||||||
ctaText,
|
ctaText,
|
||||||
proofText,
|
proofText,
|
||||||
screenshotSrc,
|
screenshotSrc,
|
||||||
|
colors,
|
||||||
|
appName = "honeyDue",
|
||||||
}) => {
|
}) => {
|
||||||
|
const COLORS = { ...DEFAULT_COLORS, ...colors };
|
||||||
const frame = useCurrentFrame();
|
const frame = useCurrentFrame();
|
||||||
const { fps, durationInFrames, width, height } = useVideoConfig();
|
const { fps, durationInFrames, width, height } = useVideoConfig();
|
||||||
|
|
||||||
@@ -283,7 +297,7 @@ export const HoneyDueAd: React.FC<HoneyDueAdProps> = ({
|
|||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
honeyDue
|
{appName}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Icon — 50% of canvas width, centered */}
|
{/* Icon — 50% of canvas width, centered */}
|
||||||
|
|||||||
@@ -15,6 +15,23 @@ model User {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model App {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
slug String @unique
|
||||||
|
description String?
|
||||||
|
primaryColor String @default("#0079FF")
|
||||||
|
accentColor String @default("#FF9400")
|
||||||
|
darkBg String @default("#1a1a2e")
|
||||||
|
appUrl String?
|
||||||
|
brandIdentity String?
|
||||||
|
productInfo String?
|
||||||
|
platformGuidelines String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
campaigns Campaign[]
|
||||||
|
}
|
||||||
|
|
||||||
model Campaign {
|
model Campaign {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
@@ -23,6 +40,8 @@ model Campaign {
|
|||||||
platforms String // JSON array: ["instagram","tiktok","nextdoor"]
|
platforms String // JSON array: ["instagram","tiktok","nextdoor"]
|
||||||
config String? // JSON: full campaign config from form
|
config String? // JSON: full campaign config from form
|
||||||
outputPath String?
|
outputPath String?
|
||||||
|
appId String?
|
||||||
|
app App? @relation(fields: [appId], references: [id])
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
agentRuns AgentRun[]
|
agentRuns AgentRun[]
|
||||||
|
|||||||
+69
-2
@@ -1,13 +1,26 @@
|
|||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { PrismaClient } from "../lib/generated/prisma/client";
|
import { PrismaClient } from "../lib/generated/prisma/client";
|
||||||
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
|
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
|
||||||
|
import { readFileSync, mkdirSync, copyFileSync, existsSync } from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
const adapter = new PrismaBetterSqlite3({
|
const adapter = new PrismaBetterSqlite3({
|
||||||
url: process.env.DATABASE_URL || "file:./data/marketing.db",
|
url: process.env.DATABASE_URL || "file:./prisma/data/marketing.db",
|
||||||
});
|
});
|
||||||
const prisma = new PrismaClient({ adapter });
|
const prisma = new PrismaClient({ adapter });
|
||||||
|
|
||||||
|
function readKnowledgeFile(filename: string): string | null {
|
||||||
|
const filePath = path.join(__dirname, "..", "pipeline", "knowledge", filename);
|
||||||
|
try {
|
||||||
|
return readFileSync(filePath, "utf-8");
|
||||||
|
} catch {
|
||||||
|
console.warn(`Knowledge file not found: ${filePath}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
// Seed admin user
|
||||||
const email = process.env.ADMIN_EMAIL || "admin@localhost";
|
const email = process.env.ADMIN_EMAIL || "admin@localhost";
|
||||||
const password = process.env.ADMIN_PASSWORD || "admin123";
|
const password = process.env.ADMIN_PASSWORD || "admin123";
|
||||||
|
|
||||||
@@ -20,8 +33,62 @@ async function main() {
|
|||||||
name: "Admin",
|
name: "Admin",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`Admin user created: ${email}`);
|
console.log(`Admin user created: ${email}`);
|
||||||
|
|
||||||
|
// Seed honeyDue app
|
||||||
|
const brandIdentity = readKnowledgeFile("brand_identity.md");
|
||||||
|
const productInfo = readKnowledgeFile("product_campaign.md");
|
||||||
|
const platformGuidelines = readKnowledgeFile("platform_guidelines.md");
|
||||||
|
|
||||||
|
const app = await prisma.app.upsert({
|
||||||
|
where: { slug: "honeydue" },
|
||||||
|
update: {
|
||||||
|
brandIdentity,
|
||||||
|
productInfo,
|
||||||
|
platformGuidelines,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
name: "honeyDue",
|
||||||
|
slug: "honeydue",
|
||||||
|
description: "Home maintenance tracking app",
|
||||||
|
primaryColor: "#0079FF",
|
||||||
|
accentColor: "#FF9400",
|
||||||
|
darkBg: "#1a1a2e",
|
||||||
|
brandIdentity,
|
||||||
|
productInfo,
|
||||||
|
platformGuidelines,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`App created: ${app.name} (${app.slug})`);
|
||||||
|
|
||||||
|
// Backfill existing campaigns with appId
|
||||||
|
const updated = await prisma.campaign.updateMany({
|
||||||
|
where: { appId: null },
|
||||||
|
data: { appId: app.id },
|
||||||
|
});
|
||||||
|
console.log(`Backfilled ${updated.count} campaigns with appId`);
|
||||||
|
|
||||||
|
// Copy assets to pipeline/apps/honeydue/
|
||||||
|
const pipelineRoot = path.join(__dirname, "..", "pipeline");
|
||||||
|
const appAssetsDir = path.join(pipelineRoot, "apps", "honeydue");
|
||||||
|
const screenshotsDir = path.join(appAssetsDir, "screenshots");
|
||||||
|
|
||||||
|
mkdirSync(screenshotsDir, { recursive: true });
|
||||||
|
|
||||||
|
const assetsToCopy = [
|
||||||
|
{ src: path.join(pipelineRoot, "assets", "icon.png"), dest: path.join(appAssetsDir, "icon.png") },
|
||||||
|
{ src: path.join(pipelineRoot, "assets", "phone.png"), dest: path.join(appAssetsDir, "phone.png") },
|
||||||
|
{ src: path.join(pipelineRoot, "assets", "screenshots", "tasks_overdue.png"), dest: path.join(screenshotsDir, "tasks_overdue.png") },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { src, dest } of assetsToCopy) {
|
||||||
|
if (existsSync(src)) {
|
||||||
|
copyFileSync(src, dest);
|
||||||
|
console.log(`Copied ${path.basename(src)} → apps/honeydue/`);
|
||||||
|
} else {
|
||||||
|
console.warn(`Asset not found: ${src}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main()
|
||||||
|
|||||||
Reference in New Issue
Block a user