From 66c2bbec8b54a7a22a1c94721b39b199822eebc9 Mon Sep 17 00:00:00 2001 From: Trey t Date: Mon, 23 Mar 2026 21:05:26 -0500 Subject: [PATCH] feat: complete marketing command center with pipeline, UI, and asset generation - Dashboard with campaign management, asset gallery, and publishing queue - 7-agent pipeline: trend scout, research, scripts, ad creative, video, copy, distribution - Campaign form with screenshot upload, goal picker, platform selection - Campaign detail view with Details/Pipeline/Assets/Chat tabs - Two-set image generation: Gemini AI (NanoBanana MCP) + Canvas Design posters - Remotion video rendering with phone.png frame and real screenshot alignment - honeyDue branding: blue #0079FF, orange #FF9400, Inter font, warm off-white - Asset cards with source badges (Gemini/Canvas/Remotion/Playwright) - Markdown/JSON render endpoint for viewing pipeline outputs as HTML - Settings page with Tavily, Gemini, Postiz, Nextdoor integration management - Claude Chat for campaign feedback loop with streaming SSE - Postiz publishing modal with scheduling - Auth with NextAuth credentials + JWT sessions - SQLite via Prisma with better-sqlite3 adapter Co-Authored-By: Claude Opus 4.6 (1M context) --- .dockerignore | 6 + .gitignore | 24 + Dockerfile | 35 + app/(auth)/layout.tsx | 11 + app/(auth)/login/page.tsx | 81 + app/(dashboard)/assets/page.tsx | 22 + .../campaigns/[id]/assets/page.tsx | 29 + app/(dashboard)/campaigns/[id]/chat/page.tsx | 14 + app/(dashboard)/campaigns/[id]/layout.tsx | 53 + app/(dashboard)/campaigns/[id]/page.tsx | 165 ++ .../campaigns/[id]/pipeline/page.tsx | 89 + app/(dashboard)/campaigns/new/page.tsx | 9 + app/(dashboard)/campaigns/page.tsx | 89 + app/(dashboard)/layout.tsx | 21 + app/(dashboard)/page.tsx | 116 + app/(dashboard)/queue/page.tsx | 106 + app/(dashboard)/settings/page.tsx | 281 +++ app/(dashboard)/trends/page.tsx | 109 + app/api/assets/[id]/route.ts | 20 + app/api/assets/route.ts | 34 + app/api/auth/[...nextauth]/route.ts | 3 + app/api/campaigns/[id]/launch/route.ts | 67 + app/api/campaigns/[id]/route.ts | 44 + app/api/campaigns/[id]/scan/route.ts | 30 + app/api/campaigns/[id]/stream/route.ts | 44 + app/api/campaigns/route.ts | 41 + app/api/claude/route.ts | 66 + app/api/files/[...path]/route.ts | 83 + app/api/files/render/[...path]/route.ts | 147 ++ app/api/nextdoor/route.ts | 83 + app/api/postiz/route.ts | 61 + app/api/settings/route.ts | 57 + app/api/stats/route.ts | 50 + app/api/uploads/route.ts | 42 + app/globals.css | 117 +- app/layout.tsx | 14 +- app/page.tsx | 65 - components/app-sidebar.tsx | 73 + components/asset-card.tsx | 195 ++ components/asset-gallery.tsx | 190 ++ components/campaign-form.tsx | 450 ++++ components/claude-chat.tsx | 82 + components/header.tsx | 47 + components/pipeline-progress.tsx | 67 + components/postiz-push-modal.tsx | 100 + components/providers.tsx | 12 + components/ui/avatar.tsx | 109 + components/ui/badge.tsx | 52 + components/ui/card.tsx | 103 + components/ui/dialog.tsx | 160 ++ components/ui/dropdown-menu.tsx | 268 +++ components/ui/input.tsx | 20 + components/ui/label.tsx | 20 + components/ui/separator.tsx | 25 + components/ui/sheet.tsx | 138 ++ components/ui/sidebar.tsx | 723 ++++++ components/ui/skeleton.tsx | 13 + components/ui/sonner.tsx | 49 + components/ui/table.tsx | 116 + components/ui/tabs.tsx | 82 + components/ui/textarea.tsx | 18 + components/ui/tooltip.tsx | 66 + docker-compose.yml | 64 + docs/plans/2026-03-23-end-to-end-flow.md | 135 ++ hooks/use-claude-chat.ts | 102 + hooks/use-mobile.ts | 19 + hooks/use-pipeline-progress.ts | 85 + icon.png | Bin 0 -> 337272 bytes lib/auth.ts | 31 + lib/claude.ts | 727 ++++++ lib/nextdoor.ts | 121 + lib/postiz.ts | 148 ++ lib/prisma.ts | 15 + lib/scanner.ts | 184 ++ lib/settings.ts | 141 ++ middleware.ts | 25 + next.config.ts | 2 +- package-lock.json | 2124 ++++++++++++++++- package.json | 18 + phone.png | Bin 0 -> 533468 bytes pipeline/.mcp.json | 11 + pipeline/CLAUDE.md | 107 + pipeline/assets/.gitkeep | 0 pipeline/assets/icon.png | Bin 0 -> 337272 bytes pipeline/assets/phone.png | Bin 0 -> 533468 bytes pipeline/assets/screenshots/.gitkeep | 0 pipeline/knowledge/brand_identity.md | 44 + pipeline/knowledge/platform_guidelines.md | 45 + pipeline/knowledge/product_campaign.md | 44 + pipeline/outputs/.gitkeep | 0 pipeline/package.json | 7 + pipeline/remotion-ad/package.json | 20 + pipeline/remotion-ad/src/AdComposition.tsx | 175 ++ pipeline/remotion-ad/src/HoneyDueAd.tsx | 318 +++ pipeline/remotion-ad/src/Root.tsx | 146 ++ pipeline/remotion-ad/src/index.ts | 4 + pipeline/remotion-ad/tsconfig.json | 12 + pipeline/scripts/research_agent.mjs | 70 + pipeline/scripts/research_queries.mjs | 103 + pipeline/scripts/tavily_trend_search.mjs | 54 + pipeline/scripts/trend_scout_search.mjs | 56 + pipeline/skills/ad-creative-designer/SKILL.md | 281 +++ pipeline/skills/copywriter-agent/SKILL.md | 279 +++ pipeline/skills/distribution-agent/SKILL.md | 335 +++ .../skills/marketing-research-agent/SKILL.md | 253 ++ pipeline/skills/script-writer/SKILL.md | 250 ++ pipeline/skills/trend-scout/SKILL.md | 182 ++ pipeline/skills/video-ad-producer/SKILL.md | 296 +++ prisma.config.ts | 15 + prisma/schema.prisma | 92 + prisma/seed-demo.ts | 199 ++ prisma/seed.ts | 32 + tsconfig.json | 2 +- 113 files changed, 12741 insertions(+), 138 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 app/(auth)/layout.tsx create mode 100644 app/(auth)/login/page.tsx create mode 100644 app/(dashboard)/assets/page.tsx create mode 100644 app/(dashboard)/campaigns/[id]/assets/page.tsx create mode 100644 app/(dashboard)/campaigns/[id]/chat/page.tsx create mode 100644 app/(dashboard)/campaigns/[id]/layout.tsx create mode 100644 app/(dashboard)/campaigns/[id]/page.tsx create mode 100644 app/(dashboard)/campaigns/[id]/pipeline/page.tsx create mode 100644 app/(dashboard)/campaigns/new/page.tsx create mode 100644 app/(dashboard)/campaigns/page.tsx create mode 100644 app/(dashboard)/layout.tsx create mode 100644 app/(dashboard)/page.tsx create mode 100644 app/(dashboard)/queue/page.tsx create mode 100644 app/(dashboard)/settings/page.tsx create mode 100644 app/(dashboard)/trends/page.tsx create mode 100644 app/api/assets/[id]/route.ts create mode 100644 app/api/assets/route.ts create mode 100644 app/api/auth/[...nextauth]/route.ts create mode 100644 app/api/campaigns/[id]/launch/route.ts create mode 100644 app/api/campaigns/[id]/route.ts create mode 100644 app/api/campaigns/[id]/scan/route.ts create mode 100644 app/api/campaigns/[id]/stream/route.ts create mode 100644 app/api/campaigns/route.ts create mode 100644 app/api/claude/route.ts create mode 100644 app/api/files/[...path]/route.ts create mode 100644 app/api/files/render/[...path]/route.ts create mode 100644 app/api/nextdoor/route.ts create mode 100644 app/api/postiz/route.ts create mode 100644 app/api/settings/route.ts create mode 100644 app/api/stats/route.ts create mode 100644 app/api/uploads/route.ts delete mode 100644 app/page.tsx create mode 100644 components/app-sidebar.tsx create mode 100644 components/asset-card.tsx create mode 100644 components/asset-gallery.tsx create mode 100644 components/campaign-form.tsx create mode 100644 components/claude-chat.tsx create mode 100644 components/header.tsx create mode 100644 components/pipeline-progress.tsx create mode 100644 components/postiz-push-modal.tsx create mode 100644 components/providers.tsx create mode 100644 components/ui/avatar.tsx create mode 100644 components/ui/badge.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/dropdown-menu.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/label.tsx create mode 100644 components/ui/separator.tsx create mode 100644 components/ui/sheet.tsx create mode 100644 components/ui/sidebar.tsx create mode 100644 components/ui/skeleton.tsx create mode 100644 components/ui/sonner.tsx create mode 100644 components/ui/table.tsx create mode 100644 components/ui/tabs.tsx create mode 100644 components/ui/textarea.tsx create mode 100644 components/ui/tooltip.tsx create mode 100644 docker-compose.yml create mode 100644 docs/plans/2026-03-23-end-to-end-flow.md create mode 100644 hooks/use-claude-chat.ts create mode 100644 hooks/use-mobile.ts create mode 100644 hooks/use-pipeline-progress.ts create mode 100644 icon.png create mode 100644 lib/auth.ts create mode 100644 lib/claude.ts create mode 100644 lib/nextdoor.ts create mode 100644 lib/postiz.ts create mode 100644 lib/prisma.ts create mode 100644 lib/scanner.ts create mode 100644 lib/settings.ts create mode 100644 middleware.ts create mode 100644 phone.png create mode 100644 pipeline/.mcp.json create mode 100644 pipeline/CLAUDE.md create mode 100644 pipeline/assets/.gitkeep create mode 100644 pipeline/assets/icon.png create mode 100644 pipeline/assets/phone.png create mode 100644 pipeline/assets/screenshots/.gitkeep create mode 100644 pipeline/knowledge/brand_identity.md create mode 100644 pipeline/knowledge/platform_guidelines.md create mode 100644 pipeline/knowledge/product_campaign.md create mode 100644 pipeline/outputs/.gitkeep create mode 100644 pipeline/package.json create mode 100644 pipeline/remotion-ad/package.json create mode 100644 pipeline/remotion-ad/src/AdComposition.tsx create mode 100644 pipeline/remotion-ad/src/HoneyDueAd.tsx create mode 100644 pipeline/remotion-ad/src/Root.tsx create mode 100644 pipeline/remotion-ad/src/index.ts create mode 100644 pipeline/remotion-ad/tsconfig.json create mode 100644 pipeline/scripts/research_agent.mjs create mode 100644 pipeline/scripts/research_queries.mjs create mode 100644 pipeline/scripts/tavily_trend_search.mjs create mode 100644 pipeline/scripts/trend_scout_search.mjs create mode 100644 pipeline/skills/ad-creative-designer/SKILL.md create mode 100644 pipeline/skills/copywriter-agent/SKILL.md create mode 100644 pipeline/skills/distribution-agent/SKILL.md create mode 100644 pipeline/skills/marketing-research-agent/SKILL.md create mode 100644 pipeline/skills/script-writer/SKILL.md create mode 100644 pipeline/skills/trend-scout/SKILL.md create mode 100644 pipeline/skills/video-ad-producer/SKILL.md create mode 100644 prisma.config.ts create mode 100644 prisma/schema.prisma create mode 100644 prisma/seed-demo.ts create mode 100644 prisma/seed.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b4e5ea5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +.next +prisma/data +.env.local +.env +.git diff --git a/.gitignore b/.gitignore index 5ef6a52..0871572 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,27 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# prisma generated client & local db +/lib/generated/prisma +prisma/data/ +data/ + +# pipeline build artifacts +pipeline/package-lock.json +pipeline/remotion-ad/package-lock.json +pipeline/node_modules/ +pipeline/remotion-ad/node_modules/ +pipeline/outputs/* +!pipeline/outputs/.gitkeep +pipeline/.next/ +pipeline/remotion-ad/public/tasks_overdue.png +pipeline/remotion-ad/public/phone.png +pipeline/remotion-ad/public/icon.png + +# claude settings (local/personal) +.claude/settings.local.json + +# uploaded screenshots (user content) +pipeline/assets/screenshots/*.png +!pipeline/assets/screenshots/.gitkeep diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..869944f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +FROM node:20-alpine AS base + +# Install Claude Code CLI +RUN npm install -g @anthropic-ai/claude-code + +# Install Playwright browsers (for ad creative generation) +RUN npx playwright install chromium --with-deps + +FROM base AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci + +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npx prisma generate +RUN npm run build + +FROM base AS runner +WORKDIR /app +ENV NODE_ENV=production + +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/public ./public +COPY --from=builder /app/prisma ./prisma +COPY --from=builder /app/lib/generated ./lib/generated + +# Copy the marketing pipeline +COPY pipeline/ ./pipeline/ + +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx new file mode 100644 index 0000000..4013ea8 --- /dev/null +++ b/app/(auth)/layout.tsx @@ -0,0 +1,11 @@ +export default function AuthLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx new file mode 100644 index 0000000..0178756 --- /dev/null +++ b/app/(auth)/login/page.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useState } from "react"; +import { signIn } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +export default function LoginPage() { + const router = useRouter(); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + setError(""); + + const formData = new FormData(e.currentTarget); + + const result = await signIn("credentials", { + email: formData.get("email") as string, + password: formData.get("password") as string, + redirect: false, + }); + + if (result?.error) { + setError("Invalid email or password"); + setLoading(false); + } else { + router.push("/"); + router.refresh(); + } + } + + return ( + + + honeyDue Marketing + Sign in to the command center + + +
+
+ + +
+
+ + +
+ {error && ( +

{error}

+ )} + +
+
+
+ ); +} diff --git a/app/(dashboard)/assets/page.tsx b/app/(dashboard)/assets/page.tsx new file mode 100644 index 0000000..0be34cc --- /dev/null +++ b/app/(dashboard)/assets/page.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { useState } from "react"; +import { AssetGallery } from "@/components/asset-gallery"; +import { PostizPushModal } from "@/components/postiz-push-modal"; + +export default function GlobalAssetsPage() { + const [pushModalIds, setPushModalIds] = useState(null); + + return ( +
+

Asset Library

+ setPushModalIds(ids)} /> + {pushModalIds && ( + setPushModalIds(null)} + /> + )} +
+ ); +} diff --git a/app/(dashboard)/campaigns/[id]/assets/page.tsx b/app/(dashboard)/campaigns/[id]/assets/page.tsx new file mode 100644 index 0000000..288e1a7 --- /dev/null +++ b/app/(dashboard)/campaigns/[id]/assets/page.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { use, useState } from "react"; +import { AssetGallery } from "@/components/asset-gallery"; +import { PostizPushModal } from "@/components/postiz-push-modal"; + +export default function CampaignAssetsPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = use(params); + const [pushModalIds, setPushModalIds] = useState(null); + + return ( + <> + setPushModalIds(ids)} + /> + {pushModalIds && ( + setPushModalIds(null)} + /> + )} + + ); +} diff --git a/app/(dashboard)/campaigns/[id]/chat/page.tsx b/app/(dashboard)/campaigns/[id]/chat/page.tsx new file mode 100644 index 0000000..ca76413 --- /dev/null +++ b/app/(dashboard)/campaigns/[id]/chat/page.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { use } from "react"; +import { ClaudeChat } from "@/components/claude-chat"; + +export default function CampaignChatPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = use(params); + + return ; +} diff --git a/app/(dashboard)/campaigns/[id]/layout.tsx b/app/(dashboard)/campaigns/[id]/layout.tsx new file mode 100644 index 0000000..5c7cfd9 --- /dev/null +++ b/app/(dashboard)/campaigns/[id]/layout.tsx @@ -0,0 +1,53 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { use } from "react"; + +const tabs = [ + { label: "Details", href: "" }, + { label: "Pipeline", href: "/pipeline" }, + { label: "Assets", href: "/assets" }, + { label: "Claude Chat", href: "/chat" }, +]; + +export default function CampaignDetailLayout({ + children, + params, +}: { + children: React.ReactNode; + params: Promise<{ id: string }>; +}) { + const { id } = use(params); + const pathname = usePathname(); + const basePath = `/campaigns/${id}`; + + return ( +
+ + {children} +
+ ); +} diff --git a/app/(dashboard)/campaigns/[id]/page.tsx b/app/(dashboard)/campaigns/[id]/page.tsx new file mode 100644 index 0000000..d0c9073 --- /dev/null +++ b/app/(dashboard)/campaigns/[id]/page.tsx @@ -0,0 +1,165 @@ +"use client"; + +import { use, useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { CampaignForm, type CampaignData } from "@/components/campaign-form"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Play } from "lucide-react"; + +interface CampaignResponse { + id: string; + name: string; + status: string; + platforms: string; + config: string | null; +} + +export default function CampaignDetailsPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = use(params); + const router = useRouter(); + const [campaign, setCampaign] = useState(null); + const [launching, setLaunching] = useState(false); + const [saved, setSaved] = useState(false); + + useEffect(() => { + fetch(`/api/campaigns/${id}`) + .then((r) => r.json()) + .then((data) => { + setCampaign(data); + setSaved(false); + }) + .catch(() => {}); + }, [id]); + + async function handleLaunch() { + setLaunching(true); + await fetch(`/api/campaigns/${id}/launch`, { method: "POST" }); + router.push(`/campaigns/${id}/pipeline`); + } + + if (!campaign) { + return

Loading...

; + } + + const config = campaign.config ? JSON.parse(campaign.config) : {}; + const platforms: string[] = JSON.parse(campaign.platforms); + const isDraft = campaign.status === "draft"; + + const initialData: CampaignData = { + id: campaign.id, + name: campaign.name, + platforms, + config: { + goal: config.goal || "app_downloads", + keyMessage: config.keyMessage || "", + socialProof: config.socialProof || "", + targetAudience: config.targetAudience || "", + visualDirection: config.visualDirection || "clean", + competitorApps: config.competitorApps || "", + variations: config.variations ?? 5, + useTrendReport: config.useTrendReport || false, + screenshots: config.screenshots || [], + }, + }; + + return ( +
+ {isDraft && ( +
+
+

Ready to launch?

+

+ Review your campaign details below, then launch the pipeline. +

+
+ +
+ )} + + {!isDraft && ( +
+ {campaign.status} + + Campaign is {campaign.status} — fields are read-only. + +
+ )} + +
+ {isDraft ? ( + + ) : ( + + )} +
+
+ ); +} + +function ReadOnlyDetails({ data }: { data: CampaignData }) { + const goalLabels: Record = { + app_downloads: "App Downloads", + brand_awareness: "Brand Awareness", + engagement: "Engagement", + }; + + const visualLabels: Record = { + clean: "Clean & Minimal", + bold: "Bold & Vibrant", + premium: "Premium & Dark", + warm: "Warm & Friendly", + tech: "Tech & Modern", + }; + + const fields = [ + { label: "Campaign Name", value: data.name }, + { label: "Platforms", value: data.platforms.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(", ") }, + { label: "Goal", value: goalLabels[data.config.goal] || data.config.goal }, + { label: "Key Message", value: data.config.keyMessage }, + { label: "Social Proof", value: data.config.socialProof }, + { label: "Target Audience", value: data.config.targetAudience }, + { label: "Visual Direction", value: visualLabels[data.config.visualDirection || ""] || data.config.visualDirection }, + { label: "Competitor Apps", value: data.config.competitorApps }, + { label: "Variations", value: String(data.config.variations ?? 5) }, + ]; + + return ( +
+
+ {fields.map((field) => ( +
+ + {field.label} + + {field.value || "—"} +
+ ))} + {data.config.screenshots && data.config.screenshots.length > 0 && ( +
+ + Screenshots + +
+ {data.config.screenshots.map((path, i) => ( + {`Screenshot + ))} +
+
+ )} +
+
+ ); +} diff --git a/app/(dashboard)/campaigns/[id]/pipeline/page.tsx b/app/(dashboard)/campaigns/[id]/pipeline/page.tsx new file mode 100644 index 0000000..9c6dcfb --- /dev/null +++ b/app/(dashboard)/campaigns/[id]/pipeline/page.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { use, useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { PipelineProgress } from "@/components/pipeline-progress"; +import { usePipelineProgress } from "@/hooks/use-pipeline-progress"; +import { Play } from "lucide-react"; + +interface Campaign { + id: string; + name: string; + status: string; + platforms: string; + agentRuns: Array<{ + id: string; + agentName: string; + status: string; + durationMs?: number; + outputSummary?: string; + error?: string; + }>; +} + +export default function CampaignPipelinePage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = use(params); + const [campaign, setCampaign] = useState(null); + const [launching, setLaunching] = useState(false); + const { agents, pipelineStatus } = usePipelineProgress( + campaign?.status === "running" ? id : null + ); + + useEffect(() => { + fetch(`/api/campaigns/${id}`) + .then((r) => r.json()) + .then(setCampaign) + .catch(() => {}); + }, [id, pipelineStatus]); + + async function handleLaunch() { + setLaunching(true); + await fetch(`/api/campaigns/${id}/launch`, { method: "POST" }); + setCampaign((prev) => (prev ? { ...prev, status: "running" } : prev)); + setLaunching(false); + } + + if (!campaign) { + return

Loading...

; + } + + // Use SSE agents if pipeline is running, otherwise use stored agentRuns + const displayAgents = + campaign.status === "running" + ? agents + : campaign.agentRuns.length > 0 + ? campaign.agentRuns.map((r) => ({ + agentName: r.agentName, + status: r.status as "pending" | "running" | "completed" | "failed", + durationMs: r.durationMs ?? undefined, + outputSummary: r.outputSummary ?? undefined, + error: r.error ?? undefined, + })) + : agents; + + return ( +
+
+
+

{campaign.name}

+ + {campaign.status} + +
+ {campaign.status === "draft" && ( + + )} +
+ + +
+ ); +} diff --git a/app/(dashboard)/campaigns/new/page.tsx b/app/(dashboard)/campaigns/new/page.tsx new file mode 100644 index 0000000..ceb371a --- /dev/null +++ b/app/(dashboard)/campaigns/new/page.tsx @@ -0,0 +1,9 @@ +import { CampaignForm } from "@/components/campaign-form"; + +export default function NewCampaignPage() { + return ( +
+ +
+ ); +} diff --git a/app/(dashboard)/campaigns/page.tsx b/app/(dashboard)/campaigns/page.tsx new file mode 100644 index 0000000..53b7990 --- /dev/null +++ b/app/(dashboard)/campaigns/page.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { buttonVariants } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Plus } from "lucide-react"; + +interface Campaign { + id: string; + name: string; + status: string; + platforms: string; + createdAt: string; + _count: { assets: number; agentRuns: number }; +} + +const statusColors: Record = { + draft: "secondary", + running: "default", + review: "outline", + approved: "default", + published: "default", +}; + +export default function CampaignsPage() { + const [campaigns, setCampaigns] = useState([]); + + useEffect(() => { + fetch("/api/campaigns") + .then((r) => r.json()) + .then(setCampaigns) + .catch(() => {}); + }, []); + + return ( +
+
+

Campaigns

+ + + New Campaign + +
+ + {campaigns.length === 0 ? ( + + +

+ No campaigns yet. Create your first one to get started. +

+
+
+ ) : ( +
+ {campaigns.map((campaign) => { + const platforms = JSON.parse(campaign.platforms) as string[]; + return ( + + + +
+ {campaign.name} + + {campaign.status} + +
+ + {platforms.join(", ")} ·{" "} + {campaign._count.assets} assets ·{" "} + {new Date(campaign.createdAt).toLocaleDateString()} + +
+
+ + ); + })} +
+ )} +
+ ); +} diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..a6765d1 --- /dev/null +++ b/app/(dashboard)/layout.tsx @@ -0,0 +1,21 @@ +import { AppSidebar } from "@/components/app-sidebar"; +import { Header } from "@/components/header"; +import { Providers } from "@/components/providers"; + +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + +
+ +
+
+
{children}
+
+
+
+ ); +} diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx new file mode 100644 index 0000000..c73954b --- /dev/null +++ b/app/(dashboard)/page.tsx @@ -0,0 +1,116 @@ +"use client"; + +import Link from "next/link"; +import { buttonVariants } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Megaphone, Image, Clock, Plus } from "lucide-react"; +import { useEffect, useState } from "react"; + +interface Stats { + activeCampaigns: number; + pendingReview: number; + publishedThisWeek: number; + recentCampaigns: Array<{ + id: string; + name: string; + status: string; + createdAt: string; + }>; +} + +export default function DashboardPage() { + const [stats, setStats] = useState(null); + + useEffect(() => { + fetch("/api/stats") + .then((r) => r.json()) + .then(setStats) + .catch(() => {}); + }, []); + + const cards = [ + { + title: "Active Campaigns", + value: stats?.activeCampaigns ?? 0, + icon: Megaphone, + description: "Currently running", + }, + { + title: "Pending Review", + value: stats?.pendingReview ?? 0, + icon: Clock, + description: "Assets awaiting approval", + }, + { + title: "Published This Week", + value: stats?.publishedThisWeek ?? 0, + icon: Image, + description: "Assets sent to platforms", + }, + ]; + + return ( +
+
+

Dashboard

+ + + New Campaign + +
+ +
+ {cards.map((card) => ( + + + + {card.title} + + + + +
{card.value}
+

+ {card.description} +

+
+
+ ))} +
+ + + + Recent Campaigns + Your latest marketing campaigns + + + {stats?.recentCampaigns && stats.recentCampaigns.length > 0 ? ( +
+ {stats.recentCampaigns.map((campaign) => ( + + {campaign.name} + {campaign.status} + + ))} +
+ ) : ( +

+ No campaigns yet. Create your first one to get started. +

+ )} +
+
+
+ ); +} diff --git a/app/(dashboard)/queue/page.tsx b/app/(dashboard)/queue/page.tsx new file mode 100644 index 0000000..d4b2eb5 --- /dev/null +++ b/app/(dashboard)/queue/page.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +interface QueueAsset { + id: string; + fileName: string; + platform?: string | null; + status: string; + postizPostId?: string | null; + metadata?: string | null; + createdAt: string; + campaign?: { name: string }; +} + +export default function QueuePage() { + const [assets, setAssets] = useState([]); + + useEffect(() => { + fetch("/api/assets?status=published") + .then((r) => r.json()) + .then(setAssets) + .catch(() => {}); + }, []); + + return ( +
+

Publishing Queue

+ + + + Scheduled & Published + + Assets pushed to Postiz for publishing + + + + {assets.length === 0 ? ( +

+ No published assets yet. Approve assets and push them to Postiz. +

+ ) : ( + + + + Asset + Campaign + Platform + Status + Post ID + + + + {assets.map((asset) => { + const metadata = asset.metadata + ? JSON.parse(asset.metadata) + : {}; + return ( + + + {asset.fileName} + {metadata.caption && ( +

+ {metadata.caption} +

+ )} +
+ {asset.campaign?.name || "—"} + + {asset.platform && ( + {asset.platform} + )} + + + {asset.status} + + + {asset.postizPostId || "—"} + +
+ ); + })} +
+
+ )} +
+
+
+ ); +} diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx new file mode 100644 index 0000000..41d8450 --- /dev/null +++ b/app/(dashboard)/settings/page.tsx @@ -0,0 +1,281 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { CheckCircle2, XCircle, Loader2, ExternalLink } from "lucide-react"; + +interface SettingsGroup { + name: string; + description: string; + docsUrl: string; + keys: string[]; +} + +interface SettingConfig { + label: string; + placeholder: string; + secret?: boolean; +} + +const SETTINGS_GROUPS: SettingsGroup[] = [ + { + name: "Postiz", + description: + "Self-hosted social media scheduling. Handles Instagram and TikTok publishing.", + docsUrl: "https://postiz.com", + keys: ["POSTIZ_URL", "POSTIZ_API_KEY"], + }, + { + name: "Tavily", + description: + "AI-powered web research. Used by the Trend Scout and Research agents.", + docsUrl: "https://tavily.com", + keys: ["TAVILY_API_KEY"], + }, + { + name: "Gemini", + description: + "Google Gemini powers NanoBanana MCP for AI image generation in static ads. ~$0.04-0.13/image.", + docsUrl: "https://aistudio.google.com/apikey", + keys: ["GEMINI_API_KEY"], + }, + { + name: "Nextdoor", + description: + "Direct Nextdoor Ads API integration for local advertising.", + docsUrl: "https://developer.nextdoor.com", + keys: ["NEXTDOOR_API_TOKEN", "NEXTDOOR_ADVERTISER_ID"], + }, +]; + +const SETTINGS_CONFIG: Record = { + POSTIZ_URL: { label: "Postiz URL", placeholder: "http://localhost:5000" }, + POSTIZ_API_KEY: { + label: "Postiz API Key", + placeholder: "your-postiz-api-key", + secret: true, + }, + TAVILY_API_KEY: { + label: "Tavily API Key", + placeholder: "tvly-...", + secret: true, + }, + GEMINI_API_KEY: { + label: "Google Gemini API Key", + placeholder: "AIza...", + secret: true, + }, + NEXTDOOR_API_TOKEN: { + label: "Nextdoor API Token", + placeholder: "your-nextdoor-token", + secret: true, + }, + NEXTDOOR_ADVERTISER_ID: { + label: "Nextdoor Advertiser ID", + placeholder: "your-advertiser-id", + }, +}; + +type IntegrationStatus = Record< + string, + { connected: boolean; error?: string } +>; + +export default function SettingsPage() { + const [settings, setSettings] = useState>({}); + const [status, setStatus] = useState({}); + const [editValues, setEditValues] = useState>({}); + const [saving, setSaving] = useState(null); + const [saved, setSaved] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetch("/api/settings?status=true") + .then((r) => r.json()) + .then((data) => { + setSettings(data.settings || {}); + setStatus(data.status || {}); + setLoading(false); + }) + .catch(() => setLoading(false)); + }, []); + + async function handleSave(key: string) { + const value = editValues[key]; + if (value === undefined) return; + + setSaving(key); + const res = await fetch("/api/settings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key, value }), + }); + + if (res.ok) { + setSaved(key); + // Refresh settings + const data = await fetch("/api/settings?status=true").then((r) => + r.json() + ); + setSettings(data.settings || {}); + setStatus(data.status || {}); + setEditValues((prev) => { + const next = { ...prev }; + delete next[key]; + return next; + }); + setTimeout(() => setSaved(null), 2000); + } + setSaving(null); + } + + function getGroupStatus(group: SettingsGroup): { + connected: boolean; + error?: string; + } { + const key = group.name.toLowerCase(); + return status[key] || { connected: false, error: "Unknown" }; + } + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+

Settings

+

+ Configure your third-party integrations. Values are stored securely in + the database and override environment variables. +

+
+ + {SETTINGS_GROUPS.map((group) => { + const groupStatus = getGroupStatus(group); + + return ( + + +
+
+ {group.name} + {groupStatus.connected ? ( + + + Connected + + ) : ( + + + {groupStatus.error || "Not connected"} + + )} +
+ + Docs + + +
+ {group.description} +
+ + {group.keys.map((key) => { + const config = SETTINGS_CONFIG[key]; + const currentValue = settings[key] || ""; + const isEditing = key in editValues; + const editValue = editValues[key] ?? ""; + + return ( +
+ +
+ + setEditValues((prev) => ({ + ...prev, + [key]: e.target.value, + })) + } + onFocus={() => { + if (!isEditing) { + // Clear masked value on focus for secret fields + setEditValues((prev) => ({ + ...prev, + [key]: "", + })); + } + }} + /> + {isEditing && ( + + )} + {isEditing && ( + + )} +
+
+ ); + })} +
+
+ ); + })} +
+ ); +} diff --git a/app/(dashboard)/trends/page.tsx b/app/(dashboard)/trends/page.tsx new file mode 100644 index 0000000..4bd7912 --- /dev/null +++ b/app/(dashboard)/trends/page.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { buttonVariants } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +interface TrendReport { + id: string; + name: string; + filePath: string; + summary?: string | null; + createdAt: string; +} + +export default function TrendsPage() { + const [reports, setReports] = useState([]); + const [selectedReport, setSelectedReport] = useState(null); + + useEffect(() => { + fetch("/api/stats") + .then((r) => r.json()) + .then((data) => { + if (data.trendReports) setReports(data.trendReports); + }) + .catch(() => {}); + }, []); + + return ( +
+

Trend Reports

+ + {reports.length === 0 ? ( + + +

+ No trend reports yet. Run the Trend Scout agent to generate + reports. +

+
+
+ ) : ( +
+ {reports.map((report) => ( + setSelectedReport(report)} + > + + {report.name} + + {new Date(report.createdAt).toLocaleDateString()} + + + {report.summary && ( + +

+ {report.summary} +

+
+ )} + + + Create Campaign from This + + +
+ ))} +
+ )} + + {/* Report Viewer */} + {selectedReport && ( + + +
+ {selectedReport.name} + +
+
+ +