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. + + + +
+ +