From 80a1ffbe4d5c3e2b7264f2916c8eb95c6cedd123 Mon Sep 17 00:00:00 2001 From: Trey t Date: Mon, 23 Mar 2026 22:21:45 -0500 Subject: [PATCH] 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) --- .gitignore | 4 + app/(auth)/login/page.tsx | 2 +- app/(dashboard)/apps/[slug]/page.tsx | 60 ++++ app/(dashboard)/apps/new/page.tsx | 9 + app/(dashboard)/apps/page.tsx | 111 +++++++ app/api/apps/[slug]/assets/route.ts | 47 +++ app/api/apps/[slug]/route.ts | 74 +++++ app/api/apps/route.ts | 69 +++++ app/api/assets/route.ts | 8 + app/api/campaigns/[id]/launch/route.ts | 55 +++- app/api/campaigns/route.ts | 7 + app/api/stats/route.ts | 11 +- app/api/uploads/route.ts | 13 +- app/layout.tsx | 4 +- components/active-app-provider.tsx | 54 ++++ components/app-form.tsx | 373 ++++++++++++++++++++++++ components/app-sidebar.tsx | 83 +++++- components/asset-card.tsx | 19 +- components/asset-gallery.tsx | 37 ++- components/providers.tsx | 5 +- hooks/use-active-app.ts | 31 ++ lib/active-app.ts | 23 ++ lib/claude.ts | 142 ++++++--- pipeline/CLAUDE.md | 8 +- pipeline/apps/honeydue/icon.png | Bin 0 -> 337272 bytes pipeline/apps/honeydue/phone.png | Bin 0 -> 533468 bytes pipeline/remotion-ad/src/HoneyDueAd.tsx | 18 +- prisma/schema.prisma | 19 ++ prisma/seed.ts | 71 ++++- 29 files changed, 1279 insertions(+), 78 deletions(-) create mode 100644 app/(dashboard)/apps/[slug]/page.tsx create mode 100644 app/(dashboard)/apps/new/page.tsx create mode 100644 app/(dashboard)/apps/page.tsx create mode 100644 app/api/apps/[slug]/assets/route.ts create mode 100644 app/api/apps/[slug]/route.ts create mode 100644 app/api/apps/route.ts create mode 100644 components/active-app-provider.tsx create mode 100644 components/app-form.tsx create mode 100644 hooks/use-active-app.ts create mode 100644 lib/active-app.ts create mode 100644 pipeline/apps/honeydue/icon.png create mode 100644 pipeline/apps/honeydue/phone.png diff --git a/.gitignore b/.gitignore index 0871572..f1f6af0 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,7 @@ pipeline/remotion-ad/public/icon.png # uploaded screenshots (user content) pipeline/assets/screenshots/*.png !pipeline/assets/screenshots/.gitkeep + +# per-app assets and dynamic knowledge files +pipeline/apps/*/screenshots/*.png +pipeline/_knowledge/ diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 0178756..73eaeac 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -44,7 +44,7 @@ export default function LoginPage() { return ( - honeyDue Marketing + Marketing Command Center Sign in to the command center diff --git a/app/(dashboard)/apps/[slug]/page.tsx b/app/(dashboard)/apps/[slug]/page.tsx new file mode 100644 index 0000000..99d00c1 --- /dev/null +++ b/app/(dashboard)/apps/[slug]/page.tsx @@ -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
Loading...
; + if (error) return
{error}
; + if (!data) return null; + + return ( +
+ +
+ ); +} diff --git a/app/(dashboard)/apps/new/page.tsx b/app/(dashboard)/apps/new/page.tsx new file mode 100644 index 0000000..bb20ca0 --- /dev/null +++ b/app/(dashboard)/apps/new/page.tsx @@ -0,0 +1,9 @@ +import { AppForm } from "@/components/app-form"; + +export default function NewAppPage() { + return ( +
+ +
+ ); +} diff --git a/app/(dashboard)/apps/page.tsx b/app/(dashboard)/apps/page.tsx new file mode 100644 index 0000000..6aa58b8 --- /dev/null +++ b/app/(dashboard)/apps/page.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetch("/api/apps") + .then((r) => r.json()) + .then((data) => { + setApps(data); + setLoading(false); + }) + .catch(() => setLoading(false)); + }, []); + + return ( +
+
+
+

Apps

+

+ Manage apps sharing this marketing pipeline +

+
+ +
+ + {loading ? ( +
Loading...
+ ) : apps.length === 0 ? ( + + + No apps yet. Create one to get started. + + + ) : ( +
+ {apps.map((app) => ( + + + +
+
+
+ {app.name} + /{app.slug} +
+
+ + +

+ {app.description || "No description"} +

+
+
+
+ {app.primaryColor} +
+
+
+ {app.accentColor} +
+ + {app._count.campaigns} campaign{app._count.campaigns !== 1 ? "s" : ""} + +
+ + + + ))} +
+ )} +
+ ); +} diff --git a/app/api/apps/[slug]/assets/route.ts b/app/api/apps/[slug]/assets/route.ts new file mode 100644 index 0000000..b75ae97 --- /dev/null +++ b/app/api/apps/[slug]/assets/route.ts @@ -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 }); +} diff --git a/app/api/apps/[slug]/route.ts b/app/api/apps/[slug]/route.ts new file mode 100644 index 0000000..6d25b1a --- /dev/null +++ b/app/api/apps/[slug]/route.ts @@ -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 }); +} diff --git a/app/api/apps/route.ts b/app/api/apps/route.ts new file mode 100644 index 0000000..f587621 --- /dev/null +++ b/app/api/apps/route.ts @@ -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 }); +} diff --git a/app/api/assets/route.ts b/app/api/assets/route.ts index daaaa8d..3a0e2fc 100644 --- a/app/api/assets/route.ts +++ b/app/api/assets/route.ts @@ -1,5 +1,6 @@ import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; +import { getActiveAppId } from "@/lib/active-app"; export async function GET(request: Request) { const session = await auth(); @@ -12,6 +13,8 @@ export async function GET(request: Request) { const status = searchParams.get("status"); const search = searchParams.get("search"); + const appId = await getActiveAppId(); + const where: Record = {}; if (campaignId) where.campaignId = campaignId; 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({ where, orderBy: { createdAt: "desc" }, diff --git a/app/api/campaigns/[id]/launch/route.ts b/app/api/campaigns/[id]/launch/route.ts index 4ec9f49..6eebe2a 100644 --- a/app/api/campaigns/[id]/launch/route.ts +++ b/app/api/campaigns/[id]/launch/route.ts @@ -1,6 +1,6 @@ import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; -import { buildCampaignPrompt, launchPipeline } from "@/lib/claude"; +import { buildCampaignPrompt, launchPipeline, type AppConfig } from "@/lib/claude"; import path from "path"; import { mkdirSync } from "fs"; @@ -13,7 +13,10 @@ export async function POST( 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) { 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 pipelineRoot = process.env.PIPELINE_ROOT || path.join(process.cwd(), "pipeline"); @@ -39,19 +59,22 @@ export async function POST( mkdirSync(path.join(pipelineRoot, outputPath, dir), { recursive: true }); } - const prompt = buildCampaignPrompt({ - name: campaign.name, - platforms: JSON.parse(campaign.platforms), - goal: config.goal || "brand awareness", - keyMessage: config.keyMessage || campaign.name, - socialProof: config.socialProof, - targetAudience: config.targetAudience, - visualDirection: config.visualDirection, - competitorApps: config.competitorApps, - variations: config.variations, - useTrendReport: config.useTrendReport, - screenshots: config.screenshots, - }); + const prompt = buildCampaignPrompt( + { + name: campaign.name, + platforms: JSON.parse(campaign.platforms), + goal: config.goal || "brand awareness", + keyMessage: config.keyMessage || campaign.name, + socialProof: config.socialProof, + targetAudience: config.targetAudience, + visualDirection: config.visualDirection, + competitorApps: config.competitorApps, + variations: config.variations, + useTrendReport: config.useTrendReport, + screenshots: config.screenshots, + }, + appConfig + ); await prisma.campaign.update({ where: { id }, @@ -59,7 +82,7 @@ export async function POST( }); // 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) ); diff --git a/app/api/campaigns/route.ts b/app/api/campaigns/route.ts index 0dda1e2..e2e666d 100644 --- a/app/api/campaigns/route.ts +++ b/app/api/campaigns/route.ts @@ -1,11 +1,15 @@ import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; +import { getActiveAppId } from "@/lib/active-app"; export async function GET() { const session = await auth(); if (!session) return new Response("Unauthorized", { status: 401 }); + const appId = await getActiveAppId(); + const campaigns = await prisma.campaign.findMany({ + where: appId ? { appId } : {}, orderBy: { createdAt: "desc" }, include: { _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({ data: { name, platforms: JSON.stringify(platforms), config: config ? JSON.stringify(config) : null, + appId, }, }); diff --git a/app/api/stats/route.ts b/app/api/stats/route.ts index c2bfe4b..cefbf04 100644 --- a/app/api/stats/route.ts +++ b/app/api/stats/route.ts @@ -1,12 +1,17 @@ import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; +import { getActiveAppId } from "@/lib/active-app"; export async function GET() { const session = await auth(); if (!session) return new Response("Unauthorized", { status: 401 }); + const appId = await getActiveAppId(); const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + const campaignWhere = appId ? { appId } : {}; + const assetCampaignFilter = appId ? { campaign: { appId } } : {}; + const [ activeCampaigns, pendingReview, @@ -15,21 +20,23 @@ export async function GET() { trendReports, ] = await Promise.all([ prisma.campaign.count({ - where: { status: { in: ["running", "review"] } }, + where: { ...campaignWhere, status: { in: ["running", "review"] } }, }), prisma.asset.count({ where: { status: "draft", - campaign: { status: { in: ["review", "running"] } }, + campaign: { ...campaignWhere, status: { in: ["review", "running"] } }, }, }), prisma.asset.count({ where: { + ...assetCampaignFilter, status: "published", createdAt: { gte: oneWeekAgo }, }, }), prisma.campaign.findMany({ + where: campaignWhere, take: 5, orderBy: { createdAt: "desc" }, select: { id: true, name: true, status: true, createdAt: true }, diff --git a/app/api/uploads/route.ts b/app/api/uploads/route.ts index be8c018..9fc97a6 100644 --- a/app/api/uploads/route.ts +++ b/app/api/uploads/route.ts @@ -1,4 +1,5 @@ import { auth } from "@/lib/auth"; +import { getActiveApp } from "@/lib/active-app"; import { writeFile, mkdir } from "fs/promises"; import path from "path"; import { randomUUID } from "crypto"; @@ -17,7 +18,15 @@ export async function POST(request: Request) { 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 }); const uploaded: { fileName: string; path: string }[] = []; @@ -34,7 +43,7 @@ export async function POST(request: Request) { uploaded.push({ fileName: file.name, - path: `assets/screenshots/${uniqueName}`, + path: `${relativeBase}/${uniqueName}`, }); } diff --git a/app/layout.tsx b/app/layout.tsx index a2cc882..40bdcac 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -13,8 +13,8 @@ const jetbrainsMono = JetBrains_Mono({ }); export const metadata: Metadata = { - title: "honeyDue — Marketing Command Center", - description: "AI-powered marketing pipeline for honeyDue", + title: "Marketing Command Center", + description: "AI-powered marketing pipeline", }; export default function RootLayout({ diff --git a/components/active-app-provider.tsx b/components/active-app-provider.tsx new file mode 100644 index 0000000..34f223d --- /dev/null +++ b/components/active-app-provider.tsx @@ -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([]); + const [activeApp, setActiveAppState] = useState(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 ( + + {children} + + ); +} diff --git a/components/app-form.tsx b/components/app-form.tsx new file mode 100644 index 0000000..3471304 --- /dev/null +++ b/components/app-form.tsx @@ -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(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 ( +
+ + + {mode === "create" ? "Create App" : "Edit App"} + + {mode === "create" + ? "Add a new app to the marketing pipeline" + : "Update app details and branding"} + + + +
+
+ + { + setName(e.target.value); + if (mode === "create") setSlug(autoSlug(e.target.value)); + }} + placeholder="My App" + required + /> +
+
+ + setSlug(e.target.value)} + placeholder="my-app" + pattern="^[a-z0-9-]+$" + required + disabled={mode === "edit"} + /> +
+
+ +
+ + setDescription(e.target.value)} + placeholder="Brief description of the app" + /> +
+ +
+ + setAppUrl(e.target.value)} + placeholder="https://apps.apple.com/app/..." + /> +

+ App Store or website URL. Used for QR codes in ad creatives. +

+
+ +
+
+ +
+ setPrimaryColor(e.target.value)} + className="h-9 w-12 cursor-pointer rounded border" + /> + setPrimaryColor(e.target.value)} + className="flex-1" + /> +
+
+
+ +
+ setAccentColor(e.target.value)} + className="h-9 w-12 cursor-pointer rounded border" + /> + setAccentColor(e.target.value)} + className="flex-1" + /> +
+
+
+ +
+ setDarkBg(e.target.value)} + className="h-9 w-12 cursor-pointer rounded border" + /> + setDarkBg(e.target.value)} + className="flex-1" + /> +
+
+
+
+
+ + + + App Icon + + {canUpload + ? "Square icon PNG used in every ad creative." + : "Save the app first, then upload the icon from the edit page."} + + + +
+ {/* Icon preview */} + {iconSrc && ( + {`${initialData?.name} { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + )} + {!iconSrc && ( +
+ +
+ )} + + {/* Upload button */} +
+ { + const file = e.target.files?.[0]; + if (file) handleIconUpload(file); + }} + /> + +

+ Recommended: 1024x1024 PNG +

+ {iconError &&

{iconError}

} +
+
+
+
+ + + + Knowledge Files + + Markdown content that agents read before generating content. + These replace the pipeline/knowledge/ files for this app. + + + +
+ +