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 ( +
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 ( +Ready to launch?
++ Review your campaign details below, then launch the pipeline. +
+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 ( ++ No campaigns yet. Create your first one to get started. +
++ {card.description} +
++ No campaigns yet. Create your first one to get started. +
+ )} ++ No published assets yet. Approve assets and push them to Postiz. +
+ ) : ( ++ Configure your third-party integrations. Values are stored securely in + the database and override environment variables. +
++ No trend reports yet. Run the Trend Scout agent to generate + reports. +
++ {report.summary} +
+$2")
+ // Inline code
+ .replace(/`([^`]+)`/g, "$1")
+ // Unordered lists
+ .replace(/^[-*]\s+(.+)$/gm, "")
+ // Single newlines in context
+ .replace(/\n/g, "
");
+
+ // Wrap consecutive
${html}
+`; +} + +export async function GET( + _request: Request, + { params }: { params: Promise<{ path: string[] }> } +) { + const session = await auth(); + if (!session) return new Response("Unauthorized", { status: 401 }); + + const { path: segments } = await params; + const filePath = path.join(PIPELINE_ROOT, ...segments); + + const resolved = path.resolve(filePath); + if (!resolved.startsWith(path.resolve(PIPELINE_ROOT))) { + return new Response("Forbidden", { status: 403 }); + } + + try { + const content = await readFile(resolved, "utf-8"); + const ext = path.extname(resolved).toLowerCase(); + + if (ext === ".md") { + return new Response(markdownToHtml(content), { + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); + } + + if (ext === ".json") { + const pretty = JSON.stringify(JSON.parse(content), null, 2); + return new Response( + `${pretty.replace(/`,
+ { headers: { "Content-Type": "text/html; charset=utf-8" } }
+ );
+ }
+
+ // HTML files served as-is
+ if (ext === ".html") {
+ return new Response(content, {
+ headers: { "Content-Type": "text/html; charset=utf-8" },
+ });
+ }
+
+ // Fallback: plain text
+ return new Response(content, {
+ headers: { "Content-Type": "text/plain; charset=utf-8" },
+ });
+ } catch {
+ return new Response("Not found", { status: 404 });
+ }
+}
diff --git a/app/api/nextdoor/route.ts b/app/api/nextdoor/route.ts
new file mode 100644
index 0000000..f9ddca7
--- /dev/null
+++ b/app/api/nextdoor/route.ts
@@ -0,0 +1,83 @@
+import { auth } from "@/lib/auth";
+import { prisma } from "@/lib/prisma";
+import {
+ createNextdoorCampaign,
+ createNextdoorAdGroup,
+ uploadNextdoorCreative,
+ createNextdoorAd,
+} from "@/lib/nextdoor";
+
+export async function POST(request: Request) {
+ const session = await auth();
+ if (!session) return new Response("Unauthorized", { status: 401 });
+
+ const { assetIds, budget, schedule, targeting, copy } = await request.json();
+
+ if (!assetIds?.length) {
+ return Response.json({ error: "assetIds required" }, { status: 400 });
+ }
+
+ try {
+ // Create campaign
+ const campaignResult = await createNextdoorCampaign(
+ `Auto Campaign ${new Date().toISOString().slice(0, 10)}`,
+ budget || 50,
+ schedule || {
+ startDate: new Date().toISOString(),
+ endDate: new Date(Date.now() + 7 * 86400000).toISOString(),
+ }
+ );
+ const ndCampaignId = campaignResult.createCampaign.campaign.id;
+
+ // Create ad group
+ const adGroupResult = await createNextdoorAdGroup(
+ ndCampaignId,
+ targeting || {}
+ );
+ const adGroupId = adGroupResult.createAdGroup.adGroup.id;
+
+ const results = [];
+
+ for (const id of assetIds) {
+ const asset = await prisma.asset.findUnique({ where: { id } });
+ if (!asset) continue;
+
+ try {
+ // Upload creative (requires public URL — would need Postiz upload first)
+ const creativeResult = await uploadNextdoorCreative(
+ `/api/files/${asset.filePath}`
+ );
+ const creativeId = creativeResult.createCreative.creative.id;
+
+ // Create ad
+ const metadata = asset.metadata ? JSON.parse(asset.metadata) : {};
+ await createNextdoorAd(adGroupId, creativeId, {
+ headline: metadata.headline || "Check this out",
+ body: metadata.caption || "",
+ ctaText: copy?.ctaText || "Learn More",
+ destinationUrl: copy?.destinationUrl || "https://example.com",
+ });
+
+ await prisma.asset.update({
+ where: { id },
+ data: { status: "published", publishedTo: JSON.stringify(["nextdoor"]) },
+ });
+
+ results.push({ id, status: "published" });
+ } catch (error) {
+ results.push({
+ id,
+ status: "failed",
+ error: error instanceof Error ? error.message : "Unknown error",
+ });
+ }
+ }
+
+ return Response.json({ campaignId: ndCampaignId, adGroupId, results });
+ } catch (error) {
+ return Response.json(
+ { error: error instanceof Error ? error.message : "Failed" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/postiz/route.ts b/app/api/postiz/route.ts
new file mode 100644
index 0000000..08ea2ef
--- /dev/null
+++ b/app/api/postiz/route.ts
@@ -0,0 +1,61 @@
+import { auth } from "@/lib/auth";
+import { prisma } from "@/lib/prisma";
+import { pushToPostiz, getPostizIntegrations } from "@/lib/postiz";
+
+export async function GET() {
+ const session = await auth();
+ if (!session) return new Response("Unauthorized", { status: 401 });
+
+ try {
+ const integrations = await getPostizIntegrations();
+ return Response.json(integrations);
+ } catch (error) {
+ return Response.json(
+ { error: error instanceof Error ? error.message : "Failed to fetch integrations" },
+ { status: 500 }
+ );
+ }
+}
+
+export async function POST(request: Request) {
+ const session = await auth();
+ if (!session) return new Response("Unauthorized", { status: 401 });
+
+ const { assetIds, scheduledAt } = await request.json();
+
+ if (!assetIds?.length || !scheduledAt) {
+ return Response.json(
+ { error: "assetIds and scheduledAt required" },
+ { status: 400 }
+ );
+ }
+
+ const results = [];
+
+ for (const id of assetIds) {
+ const asset = await prisma.asset.findUnique({ where: { id } });
+ if (!asset) continue;
+
+ try {
+ const post = await pushToPostiz(asset, scheduledAt);
+
+ await prisma.asset.update({
+ where: { id },
+ data: {
+ status: "published",
+ postizPostId: post.id,
+ },
+ });
+
+ results.push({ id, status: "scheduled", postId: post.id });
+ } catch (error) {
+ results.push({
+ id,
+ status: "failed",
+ error: error instanceof Error ? error.message : "Unknown error",
+ });
+ }
+ }
+
+ return Response.json({ results });
+}
diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts
new file mode 100644
index 0000000..d73dd84
--- /dev/null
+++ b/app/api/settings/route.ts
@@ -0,0 +1,57 @@
+import { auth } from "@/lib/auth";
+import {
+ getAllSettings,
+ saveSetting,
+ checkIntegrationStatus,
+ SETTINGS_CONFIG,
+ type SettingKey,
+} from "@/lib/settings";
+
+export async function GET(request: Request) {
+ const session = await auth();
+ if (!session) return new Response("Unauthorized", { status: 401 });
+
+ const { searchParams } = new URL(request.url);
+ const includeStatus = searchParams.get("status") === "true";
+
+ const settings = await getAllSettings();
+
+ // Mask secret values for display
+ const masked: Record = {};
+ for (const [key, value] of Object.entries(settings)) {
+ const config = SETTINGS_CONFIG[key as SettingKey];
+ if (config && "secret" in config && config.secret && value) {
+ masked[key] = value.slice(0, 4) + "..." + value.slice(-4);
+ } else {
+ masked[key] = value;
+ }
+ }
+
+ const result: Record = { settings: masked };
+
+ if (includeStatus) {
+ result.status = await checkIntegrationStatus();
+ }
+
+ return Response.json(result);
+}
+
+export async function PUT(request: Request) {
+ const session = await auth();
+ if (!session) return new Response("Unauthorized", { status: 401 });
+
+ const body = await request.json();
+ const { key, value } = body;
+
+ if (!key || typeof value !== "string") {
+ return Response.json({ error: "key and value required" }, { status: 400 });
+ }
+
+ if (!(key in SETTINGS_CONFIG)) {
+ return Response.json({ error: "Invalid setting key" }, { status: 400 });
+ }
+
+ await saveSetting(key as SettingKey, value);
+
+ return Response.json({ ok: true });
+}
diff --git a/app/api/stats/route.ts b/app/api/stats/route.ts
new file mode 100644
index 0000000..c2bfe4b
--- /dev/null
+++ b/app/api/stats/route.ts
@@ -0,0 +1,50 @@
+import { auth } from "@/lib/auth";
+import { prisma } from "@/lib/prisma";
+
+export async function GET() {
+ const session = await auth();
+ if (!session) return new Response("Unauthorized", { status: 401 });
+
+ const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
+
+ const [
+ activeCampaigns,
+ pendingReview,
+ publishedThisWeek,
+ recentCampaigns,
+ trendReports,
+ ] = await Promise.all([
+ prisma.campaign.count({
+ where: { status: { in: ["running", "review"] } },
+ }),
+ prisma.asset.count({
+ where: {
+ status: "draft",
+ campaign: { status: { in: ["review", "running"] } },
+ },
+ }),
+ prisma.asset.count({
+ where: {
+ status: "published",
+ createdAt: { gte: oneWeekAgo },
+ },
+ }),
+ prisma.campaign.findMany({
+ take: 5,
+ orderBy: { createdAt: "desc" },
+ select: { id: true, name: true, status: true, createdAt: true },
+ }),
+ prisma.trendReport.findMany({
+ orderBy: { createdAt: "desc" },
+ take: 10,
+ }),
+ ]);
+
+ return Response.json({
+ activeCampaigns,
+ pendingReview,
+ publishedThisWeek,
+ recentCampaigns,
+ trendReports,
+ });
+}
diff --git a/app/api/uploads/route.ts b/app/api/uploads/route.ts
new file mode 100644
index 0000000..be8c018
--- /dev/null
+++ b/app/api/uploads/route.ts
@@ -0,0 +1,42 @@
+import { auth } from "@/lib/auth";
+import { writeFile, mkdir } from "fs/promises";
+import path from "path";
+import { randomUUID } from "crypto";
+
+const PIPELINE_ROOT =
+ process.env.PIPELINE_ROOT || path.join(process.cwd(), "pipeline");
+
+export async function POST(request: Request) {
+ const session = await auth();
+ if (!session) return new Response("Unauthorized", { status: 401 });
+
+ const formData = await request.formData();
+ const files = formData.getAll("files") as File[];
+
+ if (files.length === 0) {
+ return Response.json({ error: "No files provided" }, { status: 400 });
+ }
+
+ const screenshotsDir = path.join(PIPELINE_ROOT, "assets", "screenshots");
+ await mkdir(screenshotsDir, { recursive: true });
+
+ const uploaded: { fileName: string; path: string }[] = [];
+
+ for (const file of files) {
+ if (!file.type.startsWith("image/")) continue;
+
+ const ext = path.extname(file.name) || ".png";
+ const uniqueName = `${randomUUID()}${ext}`;
+ const filePath = path.join(screenshotsDir, uniqueName);
+
+ const buffer = Buffer.from(await file.arrayBuffer());
+ await writeFile(filePath, buffer);
+
+ uploaded.push({
+ fileName: file.name,
+ path: `assets/screenshots/${uniqueName}`,
+ });
+ }
+
+ return Response.json({ uploaded });
+}
diff --git a/app/globals.css b/app/globals.css
index c56032b..694c50b 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -48,73 +48,74 @@
--radius-4xl: calc(var(--radius) * 2.6);
}
+/* honeyDue palette — #0079FF blue primary, #FF9400 orange accent */
:root {
- --background: oklch(1 0 0);
- --foreground: oklch(0.145 0 0);
+ --background: oklch(0.98 0.002 90);
+ --foreground: oklch(0.17 0.02 260);
--card: oklch(1 0 0);
- --card-foreground: oklch(0.145 0 0);
+ --card-foreground: oklch(0.17 0.02 260);
--popover: oklch(1 0 0);
- --popover-foreground: oklch(0.145 0 0);
- --primary: oklch(0.205 0 0);
- --primary-foreground: oklch(0.985 0 0);
- --secondary: oklch(0.97 0 0);
- --secondary-foreground: oklch(0.205 0 0);
- --muted: oklch(0.97 0 0);
- --muted-foreground: oklch(0.556 0 0);
- --accent: oklch(0.97 0 0);
- --accent-foreground: oklch(0.205 0 0);
+ --popover-foreground: oklch(0.17 0.02 260);
+ --primary: oklch(0.55 0.22 255);
+ --primary-foreground: oklch(1 0 0);
+ --secondary: oklch(0.95 0.01 90);
+ --secondary-foreground: oklch(0.17 0.02 260);
+ --muted: oklch(0.955 0.008 90);
+ --muted-foreground: oklch(0.50 0.02 260);
+ --accent: oklch(0.72 0.17 62);
+ --accent-foreground: oklch(1 0 0);
--destructive: oklch(0.577 0.245 27.325);
- --border: oklch(0.922 0 0);
- --input: oklch(0.922 0 0);
- --ring: oklch(0.708 0 0);
- --chart-1: oklch(0.87 0 0);
- --chart-2: oklch(0.556 0 0);
- --chart-3: oklch(0.439 0 0);
- --chart-4: oklch(0.371 0 0);
- --chart-5: oklch(0.269 0 0);
- --radius: 0.625rem;
- --sidebar: oklch(0.985 0 0);
- --sidebar-foreground: oklch(0.145 0 0);
- --sidebar-primary: oklch(0.205 0 0);
- --sidebar-primary-foreground: oklch(0.985 0 0);
- --sidebar-accent: oklch(0.97 0 0);
- --sidebar-accent-foreground: oklch(0.205 0 0);
- --sidebar-border: oklch(0.922 0 0);
- --sidebar-ring: oklch(0.708 0 0);
+ --border: oklch(0.91 0.008 90);
+ --input: oklch(0.91 0.008 90);
+ --ring: oklch(0.55 0.22 255);
+ --chart-1: oklch(0.55 0.22 255);
+ --chart-2: oklch(0.72 0.17 62);
+ --chart-3: oklch(0.65 0.20 145);
+ --chart-4: oklch(0.55 0.17 27);
+ --chart-5: oklch(0.60 0.18 290);
+ --radius: 0.75rem;
+ --sidebar: oklch(0.99 0.002 90);
+ --sidebar-foreground: oklch(0.17 0.02 260);
+ --sidebar-primary: oklch(0.55 0.22 255);
+ --sidebar-primary-foreground: oklch(1 0 0);
+ --sidebar-accent: oklch(0.94 0.03 255);
+ --sidebar-accent-foreground: oklch(0.35 0.15 255);
+ --sidebar-border: oklch(0.91 0.008 90);
+ --sidebar-ring: oklch(0.55 0.22 255);
}
.dark {
- --background: oklch(0.145 0 0);
- --foreground: oklch(0.985 0 0);
- --card: oklch(0.205 0 0);
- --card-foreground: oklch(0.985 0 0);
- --popover: oklch(0.205 0 0);
- --popover-foreground: oklch(0.985 0 0);
- --primary: oklch(0.922 0 0);
- --primary-foreground: oklch(0.205 0 0);
- --secondary: oklch(0.269 0 0);
- --secondary-foreground: oklch(0.985 0 0);
- --muted: oklch(0.269 0 0);
- --muted-foreground: oklch(0.708 0 0);
- --accent: oklch(0.269 0 0);
- --accent-foreground: oklch(0.985 0 0);
+ --background: oklch(0.16 0.02 260);
+ --foreground: oklch(0.96 0.005 90);
+ --card: oklch(0.21 0.02 260);
+ --card-foreground: oklch(0.96 0.005 90);
+ --popover: oklch(0.21 0.02 260);
+ --popover-foreground: oklch(0.96 0.005 90);
+ --primary: oklch(0.62 0.22 255);
+ --primary-foreground: oklch(1 0 0);
+ --secondary: oklch(0.26 0.02 260);
+ --secondary-foreground: oklch(0.96 0.005 90);
+ --muted: oklch(0.26 0.02 260);
+ --muted-foreground: oklch(0.65 0.02 260);
+ --accent: oklch(0.72 0.17 62);
+ --accent-foreground: oklch(1 0 0);
--destructive: oklch(0.704 0.191 22.216);
- --border: oklch(1 0 0 / 10%);
+ --border: oklch(1 0 0 / 12%);
--input: oklch(1 0 0 / 15%);
- --ring: oklch(0.556 0 0);
- --chart-1: oklch(0.87 0 0);
- --chart-2: oklch(0.556 0 0);
- --chart-3: oklch(0.439 0 0);
- --chart-4: oklch(0.371 0 0);
- --chart-5: oklch(0.269 0 0);
- --sidebar: oklch(0.205 0 0);
- --sidebar-foreground: oklch(0.985 0 0);
- --sidebar-primary: oklch(0.488 0.243 264.376);
- --sidebar-primary-foreground: oklch(0.985 0 0);
- --sidebar-accent: oklch(0.269 0 0);
- --sidebar-accent-foreground: oklch(0.985 0 0);
- --sidebar-border: oklch(1 0 0 / 10%);
- --sidebar-ring: oklch(0.556 0 0);
+ --ring: oklch(0.62 0.22 255);
+ --chart-1: oklch(0.62 0.22 255);
+ --chart-2: oklch(0.72 0.17 62);
+ --chart-3: oklch(0.65 0.20 145);
+ --chart-4: oklch(0.55 0.17 27);
+ --chart-5: oklch(0.60 0.18 290);
+ --sidebar: oklch(0.19 0.02 260);
+ --sidebar-foreground: oklch(0.96 0.005 90);
+ --sidebar-primary: oklch(0.62 0.22 255);
+ --sidebar-primary-foreground: oklch(1 0 0);
+ --sidebar-accent: oklch(0.28 0.04 255);
+ --sidebar-accent-foreground: oklch(0.85 0.10 255);
+ --sidebar-border: oklch(1 0 0 / 12%);
+ --sidebar-ring: oklch(0.62 0.22 255);
}
@layer base {
diff --git a/app/layout.tsx b/app/layout.tsx
index 976eb90..a2cc882 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,20 +1,20 @@
import type { Metadata } from "next";
-import { Geist, Geist_Mono } from "next/font/google";
+import { Inter, JetBrains_Mono } from "next/font/google";
import "./globals.css";
-const geistSans = Geist({
- variable: "--font-geist-sans",
+const inter = Inter({
+ variable: "--font-sans",
subsets: ["latin"],
});
-const geistMono = Geist_Mono({
+const jetbrainsMono = JetBrains_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
- title: "Create Next App",
- description: "Generated by create next app",
+ title: "honeyDue — Marketing Command Center",
+ description: "AI-powered marketing pipeline for honeyDue",
};
export default function RootLayout({
@@ -25,7 +25,7 @@ export default function RootLayout({
return (
{children}
diff --git a/app/page.tsx b/app/page.tsx
deleted file mode 100644
index 3f36f7c..0000000
--- a/app/page.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-import Image from "next/image";
-
-export default function Home() {
- return (
-
-
-
-
-
- To get started, edit the page.tsx file.
-
-
- Looking for a starting point or more instructions? Head over to{" "}
-
- Templates
- {" "}
- or the{" "}
-
- Learning
- {" "}
- center.
-
-
-
-
-
- );
-}
diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx
new file mode 100644
index 0000000..290cb68
--- /dev/null
+++ b/components/app-sidebar.tsx
@@ -0,0 +1,73 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import {
+ LayoutDashboard,
+ Megaphone,
+ Image,
+ TrendingUp,
+ Calendar,
+ Settings,
+} from "lucide-react";
+import {
+ Sidebar,
+ SidebarContent,
+ SidebarGroup,
+ SidebarGroupContent,
+ SidebarGroupLabel,
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarHeader,
+} from "@/components/ui/sidebar";
+
+const navItems = [
+ { title: "Dashboard", href: "/", icon: LayoutDashboard },
+ { title: "Campaigns", href: "/campaigns", icon: Megaphone },
+ { title: "Assets", href: "/assets", icon: Image },
+ { title: "Trends", href: "/trends", icon: TrendingUp },
+ { title: "Queue", href: "/queue", icon: Calendar },
+ { title: "Settings", href: "/settings", icon: Settings },
+];
+
+export function AppSidebar() {
+ const pathname = usePathname();
+
+ return (
+
+
+
+
+
+
+ honeyDue Marketing
+
+
+
+
+ Navigation
+
+
+ {navItems.map((item) => (
+
+ }
+ isActive={
+ item.href === "/"
+ ? pathname === "/"
+ : pathname.startsWith(item.href)
+ }
+ >
+
+ {item.title}
+
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/components/asset-card.tsx b/components/asset-card.tsx
new file mode 100644
index 0000000..208c7d5
--- /dev/null
+++ b/components/asset-card.tsx
@@ -0,0 +1,195 @@
+"use client";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Check, X, Play } from "lucide-react";
+
+interface Asset {
+ id: string;
+ type: string;
+ platform?: string | null;
+ format?: string | null;
+ fileName: string;
+ filePath: string;
+ dimensions?: string | null;
+ metadata?: string | null;
+ status: string;
+ campaign?: { name: string };
+}
+
+interface AssetCardProps {
+ asset: Asset;
+ onStatusChange: (id: string, status: string) => void;
+ selected?: boolean;
+ onSelect?: (id: string) => void;
+}
+
+export function AssetCard({
+ asset,
+ onStatusChange,
+ selected,
+ onSelect,
+}: AssetCardProps) {
+ const metadata = asset.metadata ? JSON.parse(asset.metadata) : {};
+ const isImage = asset.type === "image" || asset.format === "png" || asset.format === "jpg";
+ const isVideo = asset.type === "video" || asset.format === "mp4";
+ const fileSrc = `/api/files/${asset.filePath}`;
+
+ const pathLower = asset.filePath.toLowerCase();
+ const source = pathLower.includes("/gemini/")
+ ? "Gemini"
+ : pathLower.includes("/posters/")
+ ? "Canvas Design"
+ : isVideo
+ ? "Remotion"
+ : isImage
+ ? "Playwright"
+ : null;
+
+ const sourceColors: Record = {
+ Gemini: "text-purple-600 border-purple-200 bg-purple-50",
+ "Canvas Design": "text-amber-600 border-amber-200 bg-amber-50",
+ Remotion: "text-blue-600 border-blue-200 bg-blue-50",
+ Playwright: "text-emerald-600 border-emerald-200 bg-emerald-50",
+ };
+
+ return (
+
+ {/* Preview */}
+ onSelect?.(asset.id)}
+ >
+ {isImage && (
+ e.stopPropagation()}
+ >
+
+
+ )}
+ {isVideo && (
+ <>
+
+ e.stopPropagation()}
+ className="absolute inset-0 flex items-center justify-center bg-black/0 hover:bg-black/40 transition-colors group/play"
+ >
+
+
+
+
+ >
+ )}
+ {!isImage && !isVideo && (
+ e.stopPropagation()}
+ className="flex h-full flex-col items-center justify-center gap-2 px-4 text-center hover:bg-muted/50 transition-colors"
+ >
+
+ {asset.type}
+
+
+ {asset.fileName}
+
+
+ )}
+
+
+ {/* Info */}
+
+
+ {source && (
+
+ {source}
+
+ )}
+ {asset.platform && (
+
+ {asset.platform}
+
+ )}
+ {asset.dimensions && (
+
+ {asset.dimensions}
+
+ )}
+
+ {asset.status}
+
+
+
+ {metadata.caption && (
+
+ {metadata.caption}
+
+ )}
+
+ {asset.campaign && (
+
+ {asset.campaign.name}
+
+ )}
+
+ {/* Actions — only for images and videos */}
+ {(isImage || isVideo) ? (
+
+
+
+
+ ) : (
+ Auto-accepted
+ )}
+
+
+ );
+}
diff --git a/components/asset-gallery.tsx b/components/asset-gallery.tsx
new file mode 100644
index 0000000..195683a
--- /dev/null
+++ b/components/asset-gallery.tsx
@@ -0,0 +1,190 @@
+"use client";
+
+import { useEffect, useState, useCallback } from "react";
+import { AssetCard } from "@/components/asset-card";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+
+interface Asset {
+ id: string;
+ type: string;
+ platform?: string | null;
+ format?: string | null;
+ fileName: string;
+ filePath: string;
+ dimensions?: string | null;
+ metadata?: string | null;
+ status: string;
+ campaign?: { name: string };
+}
+
+interface AssetGalleryProps {
+ campaignId?: string;
+ onPushToPostiz?: (assetIds: string[]) => void;
+}
+
+export function AssetGallery({ campaignId, onPushToPostiz }: AssetGalleryProps) {
+ const [assets, setAssets] = useState([]);
+ const [selectedIds, setSelectedIds] = useState>(new Set());
+ const [filters, setFilters] = useState({
+ platform: "all",
+ type: "all",
+ status: "all",
+ });
+ const [search, setSearch] = useState("");
+
+ const fetchAssets = useCallback(() => {
+ const params = new URLSearchParams();
+ if (campaignId) params.set("campaignId", campaignId);
+ if (filters.platform !== "all") params.set("platform", filters.platform);
+ if (filters.type !== "all") params.set("type", filters.type);
+ if (filters.status !== "all") params.set("status", filters.status);
+ if (search) params.set("search", search);
+
+ fetch(`/api/assets?${params}`)
+ .then((r) => r.json())
+ .then(setAssets)
+ .catch(() => {});
+ }, [campaignId, filters, search]);
+
+ useEffect(() => {
+ fetchAssets();
+ }, [fetchAssets]);
+
+ async function handleStatusChange(id: string, status: string) {
+ await fetch(`/api/assets/${id}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ status }),
+ });
+ setAssets((prev) =>
+ prev.map((a) => (a.id === id ? { ...a, status } : a))
+ );
+ }
+
+ function toggleSelect(id: string) {
+ setSelectedIds((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ }
+
+ async function bulkUpdateStatus(status: string) {
+ await Promise.all(
+ Array.from(selectedIds).map((id) =>
+ fetch(`/api/assets/${id}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ status }),
+ })
+ )
+ );
+ setAssets((prev) =>
+ prev.map((a) => (selectedIds.has(a.id) ? { ...a, status } : a))
+ );
+ setSelectedIds(new Set());
+ }
+
+ return (
+
+ {/* Filters */}
+
+
+
+
+
+
+
+ setSearch(e.target.value)}
+ className="h-9 w-48"
+ />
+
+ {selectedIds.size > 0 && (
+
+
+
+ {onPushToPostiz && (
+
+ )}
+
+ )}
+
+
+ {/* Grid */}
+ {assets.length === 0 ? (
+
+ No assets yet. Launch a pipeline to generate content.
+
+ ) : (
+
+ {assets.map((asset) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/components/campaign-form.tsx b/components/campaign-form.tsx
new file mode 100644
index 0000000..6bf67d1
--- /dev/null
+++ b/components/campaign-form.tsx
@@ -0,0 +1,450 @@
+"use client";
+
+import { useRef, useState } from "react";
+import { useRouter } from "next/navigation";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { ImagePlus, X, Loader2 } from "lucide-react";
+
+export const PLATFORMS = ["instagram", "tiktok", "nextdoor"] as const;
+export const GOALS = [
+ { value: "app_downloads", label: "App Downloads", description: "Drive installs from App Store and Google Play" },
+ { value: "brand_awareness", label: "Brand Awareness", description: "Maximize reach and impressions across platforms" },
+ { value: "engagement", label: "Engagement", description: "Boost likes, comments, shares, and saves" },
+] as const;
+
+export interface CampaignData {
+ id?: string;
+ name: string;
+ platforms: string[];
+ config: {
+ goal: string;
+ keyMessage: string;
+ socialProof?: string;
+ targetAudience?: string;
+ visualDirection?: string;
+ competitorApps?: string;
+ variations?: number;
+ useTrendReport?: boolean;
+ screenshots?: string[];
+ };
+}
+
+interface CampaignFormProps {
+ initialData?: CampaignData;
+ mode?: "create" | "edit";
+}
+
+export function CampaignForm({ initialData, mode = "create" }: CampaignFormProps) {
+ const router = useRouter();
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+ const [selectedPlatforms, setSelectedPlatforms] = useState(
+ initialData?.platforms || ["instagram"]
+ );
+ const [selectedGoal, setSelectedGoal] = useState(
+ initialData?.config.goal || "app_downloads"
+ );
+ const [screenshots, setScreenshots] = useState<
+ { file?: File; preview: string; uploadedPath?: string }[]
+ >(() => {
+ // Pre-populate from existing campaign screenshots
+ const existing = initialData?.config.screenshots || [];
+ return existing.map((p) => ({
+ preview: `/api/files/${p}`,
+ uploadedPath: p,
+ }));
+ });
+ const [uploading, setUploading] = useState(false);
+ const fileInputRef = useRef(null);
+
+ function handleFilesSelected(files: FileList | null) {
+ if (!files) return;
+ const newScreenshots = Array.from(files)
+ .filter((f) => f.type.startsWith("image/"))
+ .map((file) => ({
+ file,
+ preview: URL.createObjectURL(file),
+ }));
+ setScreenshots((prev) => [...prev, ...newScreenshots]);
+ }
+
+ function removeScreenshot(index: number) {
+ setScreenshots((prev) => {
+ const next = [...prev];
+ if (next[index].preview.startsWith("blob:")) {
+ URL.revokeObjectURL(next[index].preview);
+ }
+ next.splice(index, 1);
+ return next;
+ });
+ }
+
+ async function uploadScreenshots(): Promise {
+ if (screenshots.length === 0) return [];
+
+ const needUpload = screenshots.filter((s) => !s.uploadedPath && s.file);
+ if (needUpload.length === 0) {
+ return screenshots.filter((s) => s.uploadedPath).map((s) => s.uploadedPath!);
+ }
+
+ setUploading(true);
+ const formData = new FormData();
+ for (const s of needUpload) {
+ formData.append("files", s.file!);
+ }
+
+ const res = await fetch("/api/uploads", {
+ method: "POST",
+ body: formData,
+ });
+
+ if (!res.ok) {
+ setUploading(false);
+ throw new Error("Failed to upload screenshots");
+ }
+
+ const data = await res.json();
+ const uploadedPaths: string[] = data.uploaded.map(
+ (u: { path: string }) => u.path
+ );
+
+ // Map uploaded paths back to screenshots
+ let uploadIdx = 0;
+ setScreenshots((prev) =>
+ prev.map((s) => {
+ if (!s.uploadedPath) {
+ return { ...s, uploadedPath: uploadedPaths[uploadIdx++] };
+ }
+ return s;
+ })
+ );
+
+ setUploading(false);
+
+ // Return all paths (previously uploaded + newly uploaded)
+ const allPaths: string[] = [];
+ let newIdx = 0;
+ for (const s of screenshots) {
+ if (s.uploadedPath) {
+ allPaths.push(s.uploadedPath);
+ } else {
+ allPaths.push(uploadedPaths[newIdx++]);
+ }
+ }
+ return allPaths;
+ }
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setError("");
+
+ if (selectedPlatforms.length === 0) {
+ setError("Select at least one platform.");
+ return;
+ }
+
+ setLoading(true);
+
+ let screenshotPaths: string[] = [];
+ try {
+ screenshotPaths = await uploadScreenshots();
+ } catch {
+ setError("Failed to upload screenshots. Please try again.");
+ setLoading(false);
+ return;
+ }
+
+ const formData = new FormData(e.currentTarget);
+
+ const body = {
+ name: formData.get("name") as string,
+ platforms: selectedPlatforms,
+ config: {
+ goal: formData.get("goal") as string,
+ keyMessage: formData.get("keyMessage") as string,
+ socialProof: formData.get("socialProof") as string,
+ targetAudience: formData.get("targetAudience") as string,
+ visualDirection: formData.get("visualDirection") as string,
+ competitorApps: formData.get("competitorApps") as string,
+ variations: Number(formData.get("variations")) || 5,
+ useTrendReport: formData.get("useTrendReport") === "on",
+ screenshots: screenshotPaths,
+ },
+ };
+
+ const isEdit = mode === "edit" && initialData?.id;
+ const url = isEdit
+ ? `/api/campaigns/${initialData.id}`
+ : "/api/campaigns";
+ const method = isEdit ? "PATCH" : "POST";
+
+ const res = await fetch(url, {
+ method,
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(
+ isEdit
+ ? { name: body.name, platforms: JSON.stringify(body.platforms), config: JSON.stringify(body.config) }
+ : body
+ ),
+ });
+
+ if (res.ok) {
+ if (isEdit) {
+ router.refresh();
+ } else {
+ const campaign = await res.json();
+ router.push(`/campaigns/${campaign.id}`);
+ }
+ } else {
+ const data = await res.json().catch(() => null);
+ setError(data?.error || "Failed to save campaign. Please try again.");
+ setLoading(false);
+ }
+ }
+
+ function togglePlatform(platform: string) {
+ setSelectedPlatforms((prev) =>
+ prev.includes(platform)
+ ? prev.filter((p) => p !== platform)
+ : [...prev, platform]
+ );
+ }
+
+ return (
+
+
+ {mode === "edit" ? "Edit Campaign" : "New Campaign"}
+
+ {mode === "edit"
+ ? "Update campaign details before launching"
+ : "Configure your campaign and launch the AI pipeline"}
+
+
+
+
+
+
+ );
+}
diff --git a/components/claude-chat.tsx b/components/claude-chat.tsx
new file mode 100644
index 0000000..de81c3b
--- /dev/null
+++ b/components/claude-chat.tsx
@@ -0,0 +1,82 @@
+"use client";
+
+import { useRef, useEffect, useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import { useClaudeChat } from "@/hooks/use-claude-chat";
+import { Send, Loader2 } from "lucide-react";
+
+export function ClaudeChat({ campaignId }: { campaignId: string }) {
+ const { messages, sendMessage, isStreaming } = useClaudeChat(campaignId);
+ const [input, setInput] = useState("");
+ const messagesEndRef = useRef(null);
+
+ useEffect(() => {
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
+ }, [messages]);
+
+ function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ if (!input.trim() || isStreaming) return;
+ sendMessage(input.trim());
+ setInput("");
+ }
+
+ function handleKeyDown(e: React.KeyboardEvent) {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ handleSubmit(e);
+ }
+ }
+
+ return (
+
+ {/* Messages */}
+
+ {messages.length === 0 && (
+
+ Chat with Claude about this campaign. Ask for edits, new variations,
+ or feedback on generated content.
+
+ )}
+ {messages.map((msg, i) => (
+
+
+ {msg.content}
+ {isStreaming && i === messages.length - 1 && msg.role === "assistant" && (
+
+ )}
+
+
+ ))}
+
+
+
+ {/* Input */}
+
+
+ );
+}
diff --git a/components/header.tsx b/components/header.tsx
new file mode 100644
index 0000000..c0cdbdc
--- /dev/null
+++ b/components/header.tsx
@@ -0,0 +1,47 @@
+"use client";
+
+import { signOut, useSession } from "next-auth/react";
+import { SidebarTrigger } from "@/components/ui/sidebar";
+import { Separator } from "@/components/ui/separator";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Avatar, AvatarFallback } from "@/components/ui/avatar";
+
+export function Header({ title }: { title?: string }) {
+ const { data: session } = useSession();
+ const initials = session?.user?.name
+ ? session.user.name
+ .split(" ")
+ .map((n) => n[0])
+ .join("")
+ .toUpperCase()
+ : "U";
+
+ return (
+
+
+
+ {title && {title}
}
+
+
+
+
+ {initials}
+
+
+
+ signOut({ callbackUrl: "/login" })}
+ >
+ Sign out
+
+
+
+
+
+ );
+}
diff --git a/components/pipeline-progress.tsx b/components/pipeline-progress.tsx
new file mode 100644
index 0000000..2f54e3b
--- /dev/null
+++ b/components/pipeline-progress.tsx
@@ -0,0 +1,67 @@
+"use client";
+
+import { CheckCircle2, Circle, Loader2, XCircle } from "lucide-react";
+import type { AgentStatus } from "@/hooks/use-pipeline-progress";
+
+const AGENT_LABELS: Record = {
+ "trend-scout": "Trend Scout",
+ "marketing-research-agent": "Research Agent",
+ "script-writer": "Script Writer",
+ "ad-creative-designer": "Ad Creative Designer",
+ "video-ad-producer": "Video Ad Producer",
+ "copywriter-agent": "Copywriter",
+ "distribution-agent": "Distribution Agent",
+};
+
+function formatDuration(ms?: number) {
+ if (!ms) return "";
+ if (ms < 1000) return `${ms}ms`;
+ if (ms < 60000) return `${Math.round(ms / 1000)}s`;
+ return `${Math.round(ms / 60000)}m ${Math.round((ms % 60000) / 1000)}s`;
+}
+
+function StatusIcon({ status }: { status: AgentStatus["status"] }) {
+ switch (status) {
+ case "completed":
+ return ;
+ case "running":
+ return ;
+ case "failed":
+ return ;
+ default:
+ return ;
+ }
+}
+
+export function PipelineProgress({ agents }: { agents: AgentStatus[] }) {
+ return (
+
+ {agents.map((agent) => (
+
+
+
+
+ {AGENT_LABELS[agent.agentName] || agent.agentName}
+
+ {agent.outputSummary && (
+
+ {agent.outputSummary}
+
+ )}
+ {agent.error && (
+ {agent.error}
+ )}
+
+ {agent.durationMs && (
+
+ {formatDuration(agent.durationMs)}
+
+ )}
+
+ ))}
+
+ );
+}
diff --git a/components/postiz-push-modal.tsx b/components/postiz-push-modal.tsx
new file mode 100644
index 0000000..550d013
--- /dev/null
+++ b/components/postiz-push-modal.tsx
@@ -0,0 +1,100 @@
+"use client";
+
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+
+interface PostizPushModalProps {
+ assetIds: string[];
+ onClose: () => void;
+}
+
+export function PostizPushModal({ assetIds, onClose }: PostizPushModalProps) {
+ const [scheduledAt, setScheduledAt] = useState(() => {
+ const d = new Date();
+ d.setHours(d.getHours() + 1);
+ return d.toISOString().slice(0, 16);
+ });
+ const [loading, setLoading] = useState(false);
+ const [result, setResult] = useState(null);
+
+ async function handlePush() {
+ setLoading(true);
+
+ const res = await fetch("/api/postiz", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ assetIds,
+ scheduledAt: new Date(scheduledAt).toISOString(),
+ }),
+ });
+
+ const data = await res.json();
+
+ if (res.ok) {
+ const scheduled = data.results?.filter(
+ (r: { status: string }) => r.status === "scheduled"
+ ).length;
+ setResult(`${scheduled} asset(s) scheduled successfully.`);
+ setTimeout(onClose, 2000);
+ } else {
+ setResult(`Error: ${data.error || "Failed to push"}`);
+ }
+
+ setLoading(false);
+ }
+
+ return (
+
+ );
+}
diff --git a/components/providers.tsx b/components/providers.tsx
new file mode 100644
index 0000000..8f6c237
--- /dev/null
+++ b/components/providers.tsx
@@ -0,0 +1,12 @@
+"use client";
+
+import { SessionProvider } from "next-auth/react";
+import { SidebarProvider } from "@/components/ui/sidebar";
+
+export function Providers({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx
new file mode 100644
index 0000000..e4fed86
--- /dev/null
+++ b/components/ui/avatar.tsx
@@ -0,0 +1,109 @@
+"use client"
+
+import * as React from "react"
+import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
+
+import { cn } from "@/lib/utils"
+
+function Avatar({
+ className,
+ size = "default",
+ ...props
+}: AvatarPrimitive.Root.Props & {
+ size?: "default" | "sm" | "lg"
+}) {
+ return (
+
+ )
+}
+
+function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
+ return (
+
+ )
+}
+
+function AvatarFallback({
+ className,
+ ...props
+}: AvatarPrimitive.Fallback.Props) {
+ return (
+
+ )
+}
+
+function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+ svg]:hidden",
+ "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
+ "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AvatarGroupCount({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+ svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+export {
+ Avatar,
+ AvatarImage,
+ AvatarFallback,
+ AvatarGroup,
+ AvatarGroupCount,
+ AvatarBadge,
+}
diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx
new file mode 100644
index 0000000..b20959d
--- /dev/null
+++ b/components/ui/badge.tsx
@@ -0,0 +1,52 @@
+import { mergeProps } from "@base-ui/react/merge-props"
+import { useRender } from "@base-ui/react/use-render"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+ "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
+ secondary:
+ "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
+ destructive:
+ "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
+ outline:
+ "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
+ ghost:
+ "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function Badge({
+ className,
+ variant = "default",
+ render,
+ ...props
+}: useRender.ComponentProps<"span"> & VariantProps) {
+ return useRender({
+ defaultTagName: "span",
+ props: mergeProps<"span">(
+ {
+ className: cn(badgeVariants({ variant }), className),
+ },
+ props
+ ),
+ render,
+ state: {
+ slot: "badge",
+ variant,
+ },
+ })
+}
+
+export { Badge, badgeVariants }
diff --git a/components/ui/card.tsx b/components/ui/card.tsx
new file mode 100644
index 0000000..40cac5f
--- /dev/null
+++ b/components/ui/card.tsx
@@ -0,0 +1,103 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Card({
+ className,
+ size = "default",
+ ...props
+}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
+ return (
+ img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+}
diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx
new file mode 100644
index 0000000..0e91f97
--- /dev/null
+++ b/components/ui/dialog.tsx
@@ -0,0 +1,160 @@
+"use client"
+
+import * as React from "react"
+import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { XIcon } from "lucide-react"
+
+function Dialog({ ...props }: DialogPrimitive.Root.Props) {
+ return
+}
+
+function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
+ return
+}
+
+function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
+ return
+}
+
+function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
+ return
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: DialogPrimitive.Backdrop.Props) {
+ return (
+
+ )
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: DialogPrimitive.Popup.Props & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+ }
+ >
+
+ Close
+
+ )}
+
+
+ )
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DialogFooter({
+ className,
+ showCloseButton = false,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+ {children}
+ {showCloseButton && (
+ }>
+ Close
+
+ )}
+
+ )
+}
+
+function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
+ return (
+
+ )
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: DialogPrimitive.Description.Props) {
+ return (
+
+ )
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+}
diff --git a/components/ui/dropdown-menu.tsx b/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..9d5ebbd
--- /dev/null
+++ b/components/ui/dropdown-menu.tsx
@@ -0,0 +1,268 @@
+"use client"
+
+import * as React from "react"
+import { Menu as MenuPrimitive } from "@base-ui/react/menu"
+
+import { cn } from "@/lib/utils"
+import { ChevronRightIcon, CheckIcon } from "lucide-react"
+
+function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
+ return
+}
+
+function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
+ return
+}
+
+function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
+ return
+}
+
+function DropdownMenuContent({
+ align = "start",
+ alignOffset = 0,
+ side = "bottom",
+ sideOffset = 4,
+ className,
+ ...props
+}: MenuPrimitive.Popup.Props &
+ Pick<
+ MenuPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+
+
+
+
+
+ )
+}
+
+function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
+ return
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: MenuPrimitive.GroupLabel.Props & {
+ inset?: boolean
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: MenuPrimitive.Item.Props & {
+ inset?: boolean
+ variant?: "default" | "destructive"
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
+ return
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: MenuPrimitive.SubmenuTrigger.Props & {
+ inset?: boolean
+}) {
+ return (
+
+ {children}
+
+
+ )
+}
+
+function DropdownMenuSubContent({
+ align = "start",
+ alignOffset = -3,
+ side = "right",
+ sideOffset = 0,
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ inset,
+ ...props
+}: MenuPrimitive.CheckboxItem.Props & {
+ inset?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
+ return (
+
+ )
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ inset,
+ ...props
+}: MenuPrimitive.RadioItem.Props & {
+ inset?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: MenuPrimitive.Separator.Props) {
+ return (
+
+ )
+}
+
+function DropdownMenuShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+}
diff --git a/components/ui/input.tsx b/components/ui/input.tsx
new file mode 100644
index 0000000..7d21bab
--- /dev/null
+++ b/components/ui/input.tsx
@@ -0,0 +1,20 @@
+import * as React from "react"
+import { Input as InputPrimitive } from "@base-ui/react/input"
+
+import { cn } from "@/lib/utils"
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ )
+}
+
+export { Input }
diff --git a/components/ui/label.tsx b/components/ui/label.tsx
new file mode 100644
index 0000000..74da65c
--- /dev/null
+++ b/components/ui/label.tsx
@@ -0,0 +1,20 @@
+"use client"
+
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Label({ className, ...props }: React.ComponentProps<"label">) {
+ return (
+
+ )
+}
+
+export { Label }
diff --git a/components/ui/separator.tsx b/components/ui/separator.tsx
new file mode 100644
index 0000000..6e1369e
--- /dev/null
+++ b/components/ui/separator.tsx
@@ -0,0 +1,25 @@
+"use client"
+
+import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
+
+import { cn } from "@/lib/utils"
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ ...props
+}: SeparatorPrimitive.Props) {
+ return (
+
+ )
+}
+
+export { Separator }
diff --git a/components/ui/sheet.tsx b/components/ui/sheet.tsx
new file mode 100644
index 0000000..428a091
--- /dev/null
+++ b/components/ui/sheet.tsx
@@ -0,0 +1,138 @@
+"use client"
+
+import * as React from "react"
+import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { XIcon } from "lucide-react"
+
+function Sheet({ ...props }: SheetPrimitive.Root.Props) {
+ return
+}
+
+function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
+ return
+}
+
+function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
+ return
+}
+
+function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
+ return
+}
+
+function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
+ return (
+
+ )
+}
+
+function SheetContent({
+ className,
+ children,
+ side = "right",
+ showCloseButton = true,
+ ...props
+}: SheetPrimitive.Popup.Props & {
+ side?: "top" | "right" | "bottom" | "left"
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+ }
+ >
+
+ Close
+
+ )}
+
+
+ )
+}
+
+function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
+ return (
+
+ )
+}
+
+function SheetDescription({
+ className,
+ ...props
+}: SheetPrimitive.Description.Props) {
+ return (
+
+ )
+}
+
+export {
+ Sheet,
+ SheetTrigger,
+ SheetClose,
+ SheetContent,
+ SheetHeader,
+ SheetFooter,
+ SheetTitle,
+ SheetDescription,
+}
diff --git a/components/ui/sidebar.tsx b/components/ui/sidebar.tsx
new file mode 100644
index 0000000..e2a75a6
--- /dev/null
+++ b/components/ui/sidebar.tsx
@@ -0,0 +1,723 @@
+"use client"
+
+import * as React from "react"
+import { mergeProps } from "@base-ui/react/merge-props"
+import { useRender } from "@base-ui/react/use-render"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { useIsMobile } from "@/hooks/use-mobile"
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Separator } from "@/components/ui/separator"
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Skeleton } from "@/components/ui/skeleton"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import { PanelLeftIcon } from "lucide-react"
+
+const SIDEBAR_COOKIE_NAME = "sidebar_state"
+const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
+const SIDEBAR_WIDTH = "16rem"
+const SIDEBAR_WIDTH_MOBILE = "18rem"
+const SIDEBAR_WIDTH_ICON = "3rem"
+const SIDEBAR_KEYBOARD_SHORTCUT = "b"
+
+type SidebarContextProps = {
+ state: "expanded" | "collapsed"
+ open: boolean
+ setOpen: (open: boolean) => void
+ openMobile: boolean
+ setOpenMobile: (open: boolean) => void
+ isMobile: boolean
+ toggleSidebar: () => void
+}
+
+const SidebarContext = React.createContext(null)
+
+function useSidebar() {
+ const context = React.useContext(SidebarContext)
+ if (!context) {
+ throw new Error("useSidebar must be used within a SidebarProvider.")
+ }
+
+ return context
+}
+
+function SidebarProvider({
+ defaultOpen = true,
+ open: openProp,
+ onOpenChange: setOpenProp,
+ className,
+ style,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ defaultOpen?: boolean
+ open?: boolean
+ onOpenChange?: (open: boolean) => void
+}) {
+ const isMobile = useIsMobile()
+ const [openMobile, setOpenMobile] = React.useState(false)
+
+ // This is the internal state of the sidebar.
+ // We use openProp and setOpenProp for control from outside the component.
+ const [_open, _setOpen] = React.useState(defaultOpen)
+ const open = openProp ?? _open
+ const setOpen = React.useCallback(
+ (value: boolean | ((value: boolean) => boolean)) => {
+ const openState = typeof value === "function" ? value(open) : value
+ if (setOpenProp) {
+ setOpenProp(openState)
+ } else {
+ _setOpen(openState)
+ }
+
+ // This sets the cookie to keep the sidebar state.
+ document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
+ },
+ [setOpenProp, open]
+ )
+
+ // Helper to toggle the sidebar.
+ const toggleSidebar = React.useCallback(() => {
+ return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
+ }, [isMobile, setOpen, setOpenMobile])
+
+ // Adds a keyboard shortcut to toggle the sidebar.
+ React.useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (
+ event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
+ (event.metaKey || event.ctrlKey)
+ ) {
+ event.preventDefault()
+ toggleSidebar()
+ }
+ }
+
+ window.addEventListener("keydown", handleKeyDown)
+ return () => window.removeEventListener("keydown", handleKeyDown)
+ }, [toggleSidebar])
+
+ // We add a state so that we can do data-state="expanded" or "collapsed".
+ // This makes it easier to style the sidebar with Tailwind classes.
+ const state = open ? "expanded" : "collapsed"
+
+ const contextValue = React.useMemo(
+ () => ({
+ state,
+ open,
+ setOpen,
+ isMobile,
+ openMobile,
+ setOpenMobile,
+ toggleSidebar,
+ }),
+ [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
+ )
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+function Sidebar({
+ side = "left",
+ variant = "sidebar",
+ collapsible = "offcanvas",
+ className,
+ children,
+ dir,
+ ...props
+}: React.ComponentProps<"div"> & {
+ side?: "left" | "right"
+ variant?: "sidebar" | "floating" | "inset"
+ collapsible?: "offcanvas" | "icon" | "none"
+}) {
+ const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
+
+ if (collapsible === "none") {
+ return (
+
+ {children}
+
+ )
+ }
+
+ if (isMobile) {
+ return (
+
+
+
+ Sidebar
+ Displays the mobile sidebar.
+
+ {children}
+
+
+ )
+ }
+
+ return (
+
+ {/* This is what handles the sidebar gap on desktop */}
+
+
+
+ {children}
+
+
+
+ )
+}
+
+function SidebarTrigger({
+ className,
+ onClick,
+ ...props
+}: React.ComponentProps) {
+ const { toggleSidebar } = useSidebar()
+
+ return (
+
+ )
+}
+
+function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
+ const { toggleSidebar } = useSidebar()
+
+ return (
+
+ )
+}
+
+function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
+ return (
+
+ )
+}
+
+function SidebarInput({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SidebarSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SidebarGroupLabel({
+ className,
+ render,
+ ...props
+}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
+ return useRender({
+ defaultTagName: "div",
+ props: mergeProps<"div">(
+ {
+ className: cn(
+ "flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
+ className
+ ),
+ },
+ props
+ ),
+ render,
+ state: {
+ slot: "sidebar-group-label",
+ sidebar: "group-label",
+ },
+ })
+}
+
+function SidebarGroupAction({
+ className,
+ render,
+ ...props
+}: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
+ return useRender({
+ defaultTagName: "button",
+ props: mergeProps<"button">(
+ {
+ className: cn(
+ "absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
+ className
+ ),
+ },
+ props
+ ),
+ render,
+ state: {
+ slot: "sidebar-group-action",
+ sidebar: "group-action",
+ },
+ })
+}
+
+function SidebarGroupContent({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
+ return (
+
+ )
+}
+
+function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
+ return (
+
+ )
+}
+
+const sidebarMenuButtonVariants = cva(
+ "peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
+ {
+ variants: {
+ variant: {
+ default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
+ outline:
+ "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
+ },
+ size: {
+ default: "h-8 text-sm",
+ sm: "h-7 text-xs",
+ lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+function SidebarMenuButton({
+ render,
+ isActive = false,
+ variant = "default",
+ size = "default",
+ tooltip,
+ className,
+ ...props
+}: useRender.ComponentProps<"button"> &
+ React.ComponentProps<"button"> & {
+ isActive?: boolean
+ tooltip?: string | React.ComponentProps
+ } & VariantProps) {
+ const { isMobile, state } = useSidebar()
+ const comp = useRender({
+ defaultTagName: "button",
+ props: mergeProps<"button">(
+ {
+ className: cn(sidebarMenuButtonVariants({ variant, size }), className),
+ },
+ props
+ ),
+ render: !tooltip ? render : ,
+ state: {
+ slot: "sidebar-menu-button",
+ sidebar: "menu-button",
+ size,
+ active: isActive,
+ },
+ })
+
+ if (!tooltip) {
+ return comp
+ }
+
+ if (typeof tooltip === "string") {
+ tooltip = {
+ children: tooltip,
+ }
+ }
+
+ return (
+
+ {comp}
+
+
+ )
+}
+
+function SidebarMenuAction({
+ className,
+ render,
+ showOnHover = false,
+ ...props
+}: useRender.ComponentProps<"button"> &
+ React.ComponentProps<"button"> & {
+ showOnHover?: boolean
+ }) {
+ return useRender({
+ defaultTagName: "button",
+ props: mergeProps<"button">(
+ {
+ className: cn(
+ "absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
+ showOnHover &&
+ "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
+ className
+ ),
+ },
+ props
+ ),
+ render,
+ state: {
+ slot: "sidebar-menu-action",
+ sidebar: "menu-action",
+ },
+ })
+}
+
+function SidebarMenuBadge({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SidebarMenuSkeleton({
+ className,
+ showIcon = false,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showIcon?: boolean
+}) {
+ // Random width between 50 to 90%.
+ const [width] = React.useState(() => {
+ return `${Math.floor(Math.random() * 40) + 50}%`
+ })
+
+ return (
+
+ {showIcon && (
+
+ )}
+
+
+ )
+}
+
+function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
+ return (
+
+ )
+}
+
+function SidebarMenuSubItem({
+ className,
+ ...props
+}: React.ComponentProps<"li">) {
+ return (
+
+ )
+}
+
+function SidebarMenuSubButton({
+ render,
+ size = "md",
+ isActive = false,
+ className,
+ ...props
+}: useRender.ComponentProps<"a"> &
+ React.ComponentProps<"a"> & {
+ size?: "sm" | "md"
+ isActive?: boolean
+ }) {
+ return useRender({
+ defaultTagName: "a",
+ props: mergeProps<"a">(
+ {
+ className: cn(
+ "flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
+ className
+ ),
+ },
+ props
+ ),
+ render,
+ state: {
+ slot: "sidebar-menu-sub-button",
+ sidebar: "menu-sub-button",
+ size,
+ active: isActive,
+ },
+ })
+}
+
+export {
+ Sidebar,
+ SidebarContent,
+ SidebarFooter,
+ SidebarGroup,
+ SidebarGroupAction,
+ SidebarGroupContent,
+ SidebarGroupLabel,
+ SidebarHeader,
+ SidebarInput,
+ SidebarInset,
+ SidebarMenu,
+ SidebarMenuAction,
+ SidebarMenuBadge,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarMenuSkeleton,
+ SidebarMenuSub,
+ SidebarMenuSubButton,
+ SidebarMenuSubItem,
+ SidebarProvider,
+ SidebarRail,
+ SidebarSeparator,
+ SidebarTrigger,
+ useSidebar,
+}
diff --git a/components/ui/skeleton.tsx b/components/ui/skeleton.tsx
new file mode 100644
index 0000000..0118624
--- /dev/null
+++ b/components/ui/skeleton.tsx
@@ -0,0 +1,13 @@
+import { cn } from "@/lib/utils"
+
+function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export { Skeleton }
diff --git a/components/ui/sonner.tsx b/components/ui/sonner.tsx
new file mode 100644
index 0000000..9280ee5
--- /dev/null
+++ b/components/ui/sonner.tsx
@@ -0,0 +1,49 @@
+"use client"
+
+import { useTheme } from "next-themes"
+import { Toaster as Sonner, type ToasterProps } from "sonner"
+import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
+
+const Toaster = ({ ...props }: ToasterProps) => {
+ const { theme = "system" } = useTheme()
+
+ return (
+
+ ),
+ info: (
+
+ ),
+ warning: (
+
+ ),
+ error: (
+
+ ),
+ loading: (
+
+ ),
+ }}
+ style={
+ {
+ "--normal-bg": "var(--popover)",
+ "--normal-text": "var(--popover-foreground)",
+ "--normal-border": "var(--border)",
+ "--border-radius": "var(--radius)",
+ } as React.CSSProperties
+ }
+ toastOptions={{
+ classNames: {
+ toast: "cn-toast",
+ },
+ }}
+ {...props}
+ />
+ )
+}
+
+export { Toaster }
diff --git a/components/ui/table.tsx b/components/ui/table.tsx
new file mode 100644
index 0000000..8dc13ae
--- /dev/null
+++ b/components/ui/table.tsx
@@ -0,0 +1,116 @@
+"use client"
+
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Table({ className, ...props }: React.ComponentProps<"table">) {
+ return (
+
+
+
+ )
+}
+
+function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
+ return (
+
+ )
+}
+
+function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
+ return (
+
+ )
+}
+
+function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
+ return (
+ tr]:last:border-b-0",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
+ return (
+
+ )
+}
+
+function TableHead({ className, ...props }: React.ComponentProps<"th">) {
+ return (
+
+ )
+}
+
+function TableCell({ className, ...props }: React.ComponentProps<"td">) {
+ return (
+
+ )
+}
+
+function TableCaption({
+ className,
+ ...props
+}: React.ComponentProps<"caption">) {
+ return (
+
+ )
+}
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+}
diff --git a/components/ui/tabs.tsx b/components/ui/tabs.tsx
new file mode 100644
index 0000000..56c4288
--- /dev/null
+++ b/components/ui/tabs.tsx
@@ -0,0 +1,82 @@
+"use client"
+
+import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+function Tabs({
+ className,
+ orientation = "horizontal",
+ ...props
+}: TabsPrimitive.Root.Props) {
+ return (
+
+ )
+}
+
+const tabsListVariants = cva(
+ "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
+ {
+ variants: {
+ variant: {
+ default: "bg-muted",
+ line: "gap-1 bg-transparent",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function TabsList({
+ className,
+ variant = "default",
+ ...props
+}: TabsPrimitive.List.Props & VariantProps) {
+ return (
+
+ )
+}
+
+function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
+ return (
+
+ )
+}
+
+function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
+ return (
+
+ )
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
diff --git a/components/ui/textarea.tsx b/components/ui/textarea.tsx
new file mode 100644
index 0000000..04d27f7
--- /dev/null
+++ b/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export { Textarea }
diff --git a/components/ui/tooltip.tsx b/components/ui/tooltip.tsx
new file mode 100644
index 0000000..69e8a82
--- /dev/null
+++ b/components/ui/tooltip.tsx
@@ -0,0 +1,66 @@
+"use client"
+
+import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
+
+import { cn } from "@/lib/utils"
+
+function TooltipProvider({
+ delay = 0,
+ ...props
+}: TooltipPrimitive.Provider.Props) {
+ return (
+
+ )
+}
+
+function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
+ return
+}
+
+function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
+ return
+}
+
+function TooltipContent({
+ className,
+ side = "top",
+ sideOffset = 4,
+ align = "center",
+ alignOffset = 0,
+ children,
+ ...props
+}: TooltipPrimitive.Popup.Props &
+ Pick<
+ TooltipPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..225de58
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,64 @@
+version: "3.8"
+
+services:
+ app:
+ build: .
+ ports:
+ - "3000:3000"
+ environment:
+ - NEXTAUTH_URL=http://localhost:3000
+ - NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
+ - ADMIN_EMAIL=${ADMIN_EMAIL}
+ - ADMIN_PASSWORD=${ADMIN_PASSWORD}
+ - DATABASE_URL=file:./prisma/data/marketing.db
+ - TAVILY_API_KEY=${TAVILY_API_KEY}
+ - POSTIZ_URL=http://postiz:5000
+ - POSTIZ_API_KEY=${POSTIZ_API_KEY}
+ - NEXTDOOR_API_TOKEN=${NEXTDOOR_API_TOKEN}
+ - NEXTDOOR_ADVERTISER_ID=${NEXTDOOR_ADVERTISER_ID}
+ - PIPELINE_ROOT=/app/pipeline
+ volumes:
+ - app-data:/app/prisma/data
+ - pipeline-outputs:/app/pipeline/outputs
+ - pipeline-knowledge:/app/pipeline/knowledge
+ depends_on:
+ - postiz
+
+ postiz:
+ image: ghcr.io/gitroomhq/postiz-app:latest
+ ports:
+ - "5000:5000"
+ environment:
+ - DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@postiz-db:5432/postiz
+ - REDIS_URL=redis://redis:6379
+ - NEXT_PUBLIC_BACKEND_URL=http://postiz:5000
+ - STORAGE_PROVIDER=local
+ - UPLOAD_DIRECTORY=/uploads
+ volumes:
+ - postiz-uploads:/uploads
+ - postiz-config:/config
+ depends_on:
+ - postiz-db
+ - redis
+
+ postiz-db:
+ image: postgres:16-alpine
+ environment:
+ - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
+ - POSTGRES_DB=postiz
+ volumes:
+ - postiz-pgdata:/var/lib/postgresql/data
+
+ redis:
+ image: redis:7-alpine
+ volumes:
+ - redis-data:/data
+
+volumes:
+ app-data:
+ pipeline-outputs:
+ pipeline-knowledge:
+ postiz-uploads:
+ postiz-config:
+ postiz-pgdata:
+ redis-data:
diff --git a/docs/plans/2026-03-23-end-to-end-flow.md b/docs/plans/2026-03-23-end-to-end-flow.md
new file mode 100644
index 0000000..b231321
--- /dev/null
+++ b/docs/plans/2026-03-23-end-to-end-flow.md
@@ -0,0 +1,135 @@
+# End-to-End Flow: Marketing Command Center
+
+## Complete Pipeline Flow
+
+### 1. Setup (One-time)
+- User goes to **Settings** page
+- Configures API keys:
+ - **Tavily** — for trend/research agents (free: 1000 searches/mo)
+ - **Gemini** — for NanoBanana MCP image generation (~$0.04-0.13/image)
+ - **Postiz** — URL + API key for IG/TikTok publishing
+ - **Nextdoor** — API token + advertiser ID (optional)
+- User fills in knowledge files:
+ - `pipeline/knowledge/brand_identity.md` — brand voice, CTAs, personality
+ - `pipeline/knowledge/platform_guidelines.md` — platform specs
+ - `pipeline/knowledge/product_campaign.md` — product details, features
+
+### 2. Campaign Creation
+User fills out campaign form:
+- **Name** — campaign identifier
+- **Platforms** — Instagram, TikTok, Nextdoor (checkboxes)
+- **Goal** — app downloads, brand awareness, engagement
+- **Key Message** — the core value proposition
+- **Social Proof** — download count, rating, press
+- **Target Audience** — who we're reaching
+- **Visual Direction** — clean/bold/premium/warm/tech
+- **Competitor Apps** — for research context
+- **Variations** — hook count per platform (default 5)
+- **Use Trend Report** — incorporate latest trends
+
+### 3. Pipeline Launch
+User clicks "Launch Pipeline" → system:
+1. Creates output directory: `pipeline/outputs/{name}_{date}/`
+2. Creates subdirectories: `ads/`, `scripts/`, `video/`, `copy/`
+3. Loads API keys from Settings DB into environment
+4. Spawns 7 Claude subprocess agents sequentially
+5. Streams progress via SSE to the browser
+
+### 4. Agent Execution (7 Steps)
+
+#### Step 1: Trend Scout (~12s)
+- Reads knowledge files
+- Runs 5 Tavily searches (hooks, competitors, viral formats, pain points, timely angles)
+- Outputs: `trend_report.json`
+
+#### Step 2: Marketing Research Agent (~2min)
+- Reads knowledge files + trend_report.json
+- Runs 5 deep Tavily research queries
+- Outputs: `research_results.json`, `research_brief.md`, `interactive_report.html`
+
+#### Step 3: Script Writer (~1.5min)
+- Reads knowledge + research + trends
+- Writes 5 hook variations × 3 platform styles = 15 scripts
+- Hook-Body-CTA structure with timing cues for video
+- Outputs: `scripts/scripts_all.json`, `scripts/scripts_summary.md`
+
+#### Step 4: Ad Creative Designer (~3min)
+- Reads knowledge + scripts + research
+- For each ad variation:
+ 1. Calls NanoBanana MCP `generate_image` tool with campaign-aware prompt
+ 2. Builds HTML/CSS layout at exact dimensions with generated image + headline + CTA
+ 3. Launches Playwright → screenshots to PNG
+- Outputs: `ads/*.png` + `ads/ad_manifest.json`
+- Dimensions: IG Feed 1080x1080, IG Stories 1080x1920, ND Spotlight 1200x1200, ND Display 1200x628
+
+#### Step 5: Video Ad Producer (~5min)
+- Reads knowledge + scripts + ad manifest
+- Creates scene_plan.json per video (hook/problem/solution/proof/CTA scenes)
+- Creates/modifies Remotion compositions in `remotion-ad/src/`
+- Renders via `npx remotion render` → MP4 files
+- Outputs: `video/*.mp4` + `video/scene_plans.json`
+- Styles: polished (IG), authentic (TikTok), local (Nextdoor)
+
+#### Step 6: Copywriter Agent (~1min)
+- Reads knowledge + research + scripts
+- Writes platform-tuned captions: Hook → Value → CTA → Hashtags
+- Outputs: `copy/instagram_captions.json`, `copy/tiktok_captions.json`, `copy/nextdoor_posts.json`, `copy/copy_matrix.json`
+
+#### Step 7: Distribution Agent (~30s)
+- Gathers all outputs
+- Creates `Publish_manifest.md` with:
+ - All assets with file paths
+ - Recommended caption per asset
+ - Recommended posting schedule
+ - Review checklist
+- DOES NOT publish — waits for human approval
+
+### 5. Asset Scanning
+After pipeline completes:
+- Scanner reads all files in output directory
+- Creates Asset records in database
+- Infers: platform (from filename), dimensions, type
+- Loads metadata from adjacent JSON files
+- All assets start in "draft" status
+
+### 6. Review
+User goes to campaign Assets tab:
+- Grid view of all generated content
+- Images shown inline, videos play on hover
+- Filter by: platform, type, status
+- Each asset shows: platform badge, dimensions, caption preview
+- User clicks Approve or Reject per asset
+- Bulk select → Approve/Reject multiple
+
+### 7. Claude Chat (Feedback Loop)
+User goes to Claude Chat tab:
+- Chat with Claude about the campaign
+- "Make the hooks snarkier"
+- "Regenerate the TikTok ad with darker colors"
+- Claude has access to all pipeline files
+- New/modified assets appear after re-scan
+
+### 8. Publishing
+User selects approved assets → Push to Postiz:
+- Modal shows selected assets with schedule date/time picker
+- Postiz uploads media, creates scheduled posts
+- Assets marked as "published" with Postiz post ID
+
+For Nextdoor:
+- Separate "Push to Nextdoor" flow
+- Creates campaign → ad group → creative → ad via GraphQL API
+
+### 9. Queue
+Queue page shows all published/scheduled assets with status tracking.
+
+## Integration Requirements
+
+| Integration | Required? | API Key Source | What It Does |
+|------------|-----------|----------------|-------------|
+| Tavily | Yes | Settings page | Web research for trends + market analysis |
+| Google Gemini | Yes | Settings page | Powers NanoBanana MCP image generation |
+| Playwright | Built-in | npm package | HTML-to-PNG rendering for static ads |
+| Remotion | Built-in | npm package | React-to-MP4 video rendering |
+| Postiz | For publishing | Settings page | Instagram + TikTok scheduling |
+| Nextdoor | Optional | Settings page | Direct Nextdoor Ads API |
+| Claude Code | Required | Max plan OAuth | Pipeline engine (subprocess spawning) |
diff --git a/hooks/use-claude-chat.ts b/hooks/use-claude-chat.ts
new file mode 100644
index 0000000..588a8a5
--- /dev/null
+++ b/hooks/use-claude-chat.ts
@@ -0,0 +1,102 @@
+"use client";
+
+import { useState, useCallback } from "react";
+
+interface Message {
+ role: "user" | "assistant";
+ content: string;
+}
+
+export function useClaudeChat(campaignId: string) {
+ const [messages, setMessages] = useState([]);
+ const [isStreaming, setIsStreaming] = useState(false);
+ const [sessionId, setSessionId] = useState(null);
+
+ const sendMessage = useCallback(
+ async (content: string) => {
+ const userMessage: Message = { role: "user", content };
+ setMessages((prev) => [...prev, userMessage]);
+ setIsStreaming(true);
+
+ const assistantMessage: Message = { role: "assistant", content: "" };
+ setMessages((prev) => [...prev, assistantMessage]);
+
+ try {
+ const res = await fetch("/api/claude", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ message: content,
+ sessionId,
+ campaignId,
+ }),
+ });
+
+ if (!res.ok || !res.body) {
+ setMessages((prev) => {
+ const updated = [...prev];
+ updated[updated.length - 1] = {
+ role: "assistant",
+ content: "Failed to get response from Claude.",
+ };
+ return updated;
+ });
+ setIsStreaming(false);
+ return;
+ }
+
+ const reader = res.body.getReader();
+ const decoder = new TextDecoder();
+
+ let buffer = "";
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split("\n");
+ buffer = lines.pop() || "";
+
+ for (const line of lines) {
+ if (!line.startsWith("data: ")) continue;
+ try {
+ const data = JSON.parse(line.slice(6));
+ if (data.done) continue;
+ if (data.text) {
+ setMessages((prev) => {
+ const updated = [...prev];
+ const last = updated[updated.length - 1];
+ updated[updated.length - 1] = {
+ ...last,
+ content: last.content + data.text,
+ };
+ return updated;
+ });
+ }
+ if (data.sessionId) {
+ setSessionId(data.sessionId);
+ }
+ } catch {
+ // skip malformed lines
+ }
+ }
+ }
+ } catch {
+ setMessages((prev) => {
+ const updated = [...prev];
+ updated[updated.length - 1] = {
+ role: "assistant",
+ content: "Connection error. Please try again.",
+ };
+ return updated;
+ });
+ }
+
+ setIsStreaming(false);
+ },
+ [campaignId, sessionId]
+ );
+
+ return { messages, sendMessage, isStreaming };
+}
diff --git a/hooks/use-mobile.ts b/hooks/use-mobile.ts
new file mode 100644
index 0000000..2b0fe1d
--- /dev/null
+++ b/hooks/use-mobile.ts
@@ -0,0 +1,19 @@
+import * as React from "react"
+
+const MOBILE_BREAKPOINT = 768
+
+export function useIsMobile() {
+ const [isMobile, setIsMobile] = React.useState(undefined)
+
+ React.useEffect(() => {
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
+ const onChange = () => {
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
+ }
+ mql.addEventListener("change", onChange)
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
+ return () => mql.removeEventListener("change", onChange)
+ }, [])
+
+ return !!isMobile
+}
diff --git a/hooks/use-pipeline-progress.ts b/hooks/use-pipeline-progress.ts
new file mode 100644
index 0000000..e62e4d1
--- /dev/null
+++ b/hooks/use-pipeline-progress.ts
@@ -0,0 +1,85 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+export interface AgentStatus {
+ agentName: string;
+ status: "pending" | "running" | "completed" | "failed";
+ durationMs?: number;
+ outputSummary?: string;
+ error?: string;
+}
+
+const AGENT_NAMES = [
+ "trend-scout",
+ "marketing-research-agent",
+ "script-writer",
+ "ad-creative-designer",
+ "video-ad-producer",
+ "copywriter-agent",
+ "distribution-agent",
+];
+
+export function usePipelineProgress(campaignId: string | null) {
+ const [agents, setAgents] = useState(
+ AGENT_NAMES.map((name) => ({ agentName: name, status: "pending" }))
+ );
+ const [pipelineStatus, setPipelineStatus] = useState("idle");
+
+ useEffect(() => {
+ if (!campaignId) return;
+
+ const source = new EventSource(`/api/campaigns/${campaignId}/stream`);
+
+ source.onmessage = (event) => {
+ try {
+ const data = JSON.parse(event.data);
+
+ if (data.type === "pipeline_started") {
+ setPipelineStatus("running");
+ } else if (data.type === "agent_started") {
+ setAgents((prev) =>
+ prev.map((a) =>
+ a.agentName === data.agentName
+ ? { ...a, status: "running" }
+ : a
+ )
+ );
+ } else if (data.type === "agent_completed") {
+ setAgents((prev) =>
+ prev.map((a) =>
+ a.agentName === data.agentName
+ ? {
+ ...a,
+ status: "completed",
+ durationMs: data.durationMs,
+ outputSummary: data.outputSummary,
+ }
+ : a
+ )
+ );
+ } else if (data.type === "agent_failed") {
+ setAgents((prev) =>
+ prev.map((a) =>
+ a.agentName === data.agentName
+ ? { ...a, status: "failed", error: data.error }
+ : a
+ )
+ );
+ } else if (data.type === "pipeline_complete") {
+ setPipelineStatus(data.status || "complete");
+ }
+ } catch {
+ // ignore parse errors
+ }
+ };
+
+ source.onerror = () => {
+ source.close();
+ };
+
+ return () => source.close();
+ }, [campaignId]);
+
+ return { agents, pipelineStatus };
+}
diff --git a/icon.png b/icon.png
new file mode 100644
index 0000000..eb5c2fc
Binary files /dev/null and b/icon.png differ
diff --git a/lib/auth.ts b/lib/auth.ts
new file mode 100644
index 0000000..931d57c
--- /dev/null
+++ b/lib/auth.ts
@@ -0,0 +1,31 @@
+import NextAuth from "next-auth";
+import Credentials from "next-auth/providers/credentials";
+import bcrypt from "bcryptjs";
+import { prisma } from "./prisma";
+
+export const { handlers, signIn, signOut, auth } = NextAuth({
+ providers: [
+ Credentials({
+ credentials: {
+ email: { label: "Email", type: "email" },
+ password: { label: "Password", type: "password" },
+ },
+ async authorize(credentials) {
+ const user = await prisma.user.findUnique({
+ where: { email: credentials.email as string },
+ });
+ if (!user) return null;
+ const valid = await bcrypt.compare(
+ credentials.password as string,
+ user.password
+ );
+ return valid ? { id: user.id, email: user.email, name: user.name } : null;
+ },
+ }),
+ ],
+ session: { strategy: "jwt" },
+ pages: {
+ signIn: "/login",
+ },
+ secret: process.env.NEXTAUTH_SECRET,
+});
diff --git a/lib/claude.ts b/lib/claude.ts
new file mode 100644
index 0000000..00cd5ec
--- /dev/null
+++ b/lib/claude.ts
@@ -0,0 +1,727 @@
+import { spawn } from "child_process";
+import { mkdirSync } from "fs";
+import path from "path";
+import { EventEmitter } from "events";
+import { prisma } from "./prisma";
+import { scanOutputDirectory } from "./scanner";
+import { getAllSettings } from "./settings";
+
+export const pipelineEvents = new EventEmitter();
+pipelineEvents.setMaxListeners(50);
+
+const AGENT_STEPS = [
+ "trend-scout",
+ "marketing-research-agent",
+ "script-writer",
+ "ad-creative-designer",
+ "video-ad-producer",
+ "copywriter-agent",
+ "distribution-agent",
+] as const;
+
+export type AgentName = (typeof AGENT_STEPS)[number];
+
+interface CampaignConfig {
+ name: string;
+ platforms: string[];
+ goal: string;
+ keyMessage: string;
+ socialProof?: string;
+ variations?: number;
+ useTrendReport?: boolean;
+ targetAudience?: string;
+ visualDirection?: string;
+ competitorApps?: string;
+ screenshots?: string[];
+}
+
+/**
+ * Build a detailed campaign prompt that gives each agent enough context.
+ */
+export function buildCampaignPrompt(config: CampaignConfig): string {
+ const platforms = config.platforms.join(", ");
+ const variations = config.variations ?? 5;
+ const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, "");
+ const taskName = config.name.replace(/\s+/g, "_").toLowerCase();
+ const outputDir = `outputs/${taskName}_${dateStr}`;
+
+ return `# Campaign Brief: "${config.name}"
+
+## Goal
+${config.goal === "app_downloads" ? "Drive app downloads" : config.goal === "brand_awareness" ? "Build brand awareness" : "Maximize engagement"}
+
+## Target Platforms
+${platforms}
+
+## Key Message
+${config.keyMessage}
+
+${config.socialProof ? `## Social Proof\n${config.socialProof}` : ""}
+${config.targetAudience ? `## Target Audience\n${config.targetAudience}` : ""}
+${config.visualDirection ? `## Visual Direction\n${config.visualDirection}` : ""}
+${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.` : ""}
+
+## Content Requirements
+- ${variations} hook variations per platform
+- Static ads at exact platform dimensions (see knowledge/platform_guidelines.md)
+- Video ads with platform-appropriate styles:
+ - Instagram: "polished" style — clean motion graphics
+ - TikTok: "authentic" style — raw, native feel
+ - Nextdoor: "local" style — warm, community-focused
+- Platform-tuned captions with hashtags (except Nextdoor)
+
+## Output Directory
+All outputs MUST go to: ${outputDir}/
+ - ${outputDir}/ads/ — static PNG images
+ - ${outputDir}/scripts/ — ad scripts JSON + summary
+ - ${outputDir}/video/ — rendered MP4 files
+ - ${outputDir}/copy/ — platform caption JSON files
+ - ${outputDir}/trend_report.json — trend analysis
+ - ${outputDir}/research_results.json — research data
+ - ${outputDir}/research_brief.md — research summary
+ - ${outputDir}/Publish_${taskName}_${dateStr}.md — publish manifest
+
+${config.useTrendReport ? "## Use Latest Trends\nCheck outputs/ for the most recent trend_report.json and use those hooks as inspiration.\n" : ""}
+
+## Instructions
+Read CLAUDE.md first. Then execute each agent skill in order:
+1. Read knowledge files (brand_identity.md, platform_guidelines.md, product_campaign.md)
+2. Run trend-scout skill
+3. Run marketing-research-agent skill
+4. Run script-writer skill
+5. Run ad-creative-designer skill
+6. Run video-ad-producer skill
+7. Run copywriter-agent skill
+8. Run distribution-agent skill
+
+CRITICAL: Read each skill's SKILL.md before executing. Follow the skill instructions exactly.`;
+}
+
+/**
+ * Build a focused prompt for a single agent step.
+ */
+function buildAgentPrompt(
+ agentName: string,
+ campaignBrief: string,
+ outputDir: string,
+ screenshots?: string[]
+): string {
+ const agentInstructions: Record = {
+ "trend-scout": `You are the Trend Scout agent.
+
+Read and follow the skill instructions in skills/trend-scout/SKILL.md exactly.
+
+First, read these knowledge files:
+- knowledge/brand_identity.md
+- knowledge/platform_guidelines.md
+- knowledge/product_campaign.md
+
+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.
+
+Save your output to: ${outputDir}/trend_report.json
+
+${campaignBrief}`,
+
+ "marketing-research-agent": `You are the Marketing Research Agent.
+
+Read and follow the skill instructions in skills/marketing-research-agent/SKILL.md exactly.
+
+First, read these knowledge files:
+- knowledge/brand_identity.md
+- knowledge/platform_guidelines.md
+- knowledge/product_campaign.md
+
+Read the trend report from: ${outputDir}/trend_report.json (if it exists)
+
+Then execute 5 deep Tavily research queries. Write a Node.js script using @tavily/core to run the searches.
+
+Save outputs to:
+- ${outputDir}/research_results.json
+- ${outputDir}/research_brief.md
+- ${outputDir}/interactive_report.html
+
+${campaignBrief}`,
+
+ "script-writer": `You are the Script Writer agent.
+
+Read and follow the skill instructions in skills/script-writer/SKILL.md exactly.
+
+First, read these knowledge files:
+- knowledge/brand_identity.md
+- knowledge/platform_guidelines.md
+- knowledge/product_campaign.md
+
+Read the upstream outputs:
+- ${outputDir}/trend_report.json
+- ${outputDir}/research_results.json
+- ${outputDir}/research_brief.md
+
+Write ad scripts with hook-body-CTA structure, timed for video.
+
+Save outputs to: ${outputDir}/scripts/
+- ${outputDir}/scripts/scripts_all.json
+- ${outputDir}/scripts/scripts_summary.md
+- Individual script files per platform
+
+${campaignBrief}`,
+
+ "ad-creative-designer": `You are the Ad Creative Designer agent.
+
+Read and follow the skill instructions in skills/ad-creative-designer/SKILL.md exactly.
+
+First, read these knowledge files:
+- knowledge/brand_identity.md
+- knowledge/platform_guidelines.md
+- knowledge/product_campaign.md
+
+Read the upstream outputs:
+- ${outputDir}/scripts/scripts_all.json
+- ${outputDir}/research_brief.md
+
+You MUST produce TWO SETS of image assets:
+
+---
+
+## SET 1: Gemini AI-Generated Ads (NanoBanana MCP)
+
+Use the NanoBanana MCP tools to create polished ad images.${screenshots?.length ? `
+
+CRITICAL — REFERENCE IMAGES:
+The user provided real app screenshots that MUST be used as reference images in every generate call.
+Screenshot absolute paths:
+${screenshots.map((p) => `- \${CWD}/${p}`).join("\n")}
+
+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.
+
+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)
+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 ? `
+ - 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
+3. Save to ${outputDir}/ads/gemini/
+4. Name files: gemini_{platform}_{hook}_{dimensions}.png
+
+Generate exactly 4 Gemini ads with this mix:
+- 2 WITHOUT people (product-focused, phone mockup + environment/abstract):
+ - 1x Instagram Feed 1080x1080
+ - 1x TikTok 1080x1920
+- 2 WITH people (lifestyle, person interacting with the app):
+ - 1x Instagram Stories 1080x1920
+ - 1x TikTok 1080x1920
+
+IMPORTANT: For ads WITH people, show real-looking people naturally using the app — not stock photo poses. For ads WITHOUT people, focus on the phone/app in an environment (floating over house, on a counter, etc.)
+
+---
+
+## SET 2: Canvas Design Posters (Museum-quality art)
+
+Create poster ads using the /skill canvas-design approach. This is a TWO-STEP process:
+
+### Step A: Design Philosophy
+First, create a visual design philosophy (.md file) for this campaign's poster aesthetic. Save it to ${outputDir}/ads/posters/design_philosophy.md
+
+The philosophy should:
+- Name the aesthetic movement (1-2 words, e.g. "Domestic Geometry" or "Maintenance Modernism")
+- 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
+- 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
+- Brand palette as foundation: #0079FF blue, #FF9400 orange, warm off-white, dark navy
+
+### Step B: Express the Philosophy as Poster Art
+Using the philosophy, create each poster as a .png file. For each:
+1. Write a Python or Node.js script that generates the poster programmatically (using canvas/PDF libraries, or build HTML and screenshot with Playwright)
+2. Treat each poster as an ART OBJECT — 90% visual design, 10% essential text
+3. Use repeating patterns, perfect geometric shapes, systematic visual language
+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
+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"}
+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
+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
element in the HTML.
+
+### 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.
+
+- **Minimum font size: 44px** — NO text below 44px, ever. Clamp anything smaller up to 44px.
+- **Text scale multiplier: 1.13x** — apply 113% to all font sizes before rendering to compensate for viewing distance.
+- **Hero headline: 75-100px effective** (66-88px raw × 1.13)
+- **Body/subheadline: 44px minimum** (even if design calls for smaller)
+- **Stat labels, CTA secondary text: 44px minimum**
+- **Brand name: ~50px effective** (44px × 1.13)
+- **Minimum gap between stacked text:**
+ - Giant (100-140px) above + Large below: 50px gap
+ - Large above + Medium below: 40px gap
+ - Medium + Medium: 30px gap
+ - Absolute minimum gap: 20px
+- **2x internal render recommended:** Render at 2160×3840 (for 9:16) or 2160×2160 (for 1:1), downsample to 1080 for export — produces cleaner anti-aliasing on text and curves.
+- **Export as JPEG quality=95, subsampling=0** (4:4:4 chroma) — NOT PNG. Instagram converts PNG to JPEG anyway.
+- **CTA block at fixed bottom position:** CTA_BOTTOM_MARGIN = 60px from bottom edge. All visual content centered between header bottom and CTA top.
+- **Overflow check:** Always calculate total vertical extent before rendering. If content exceeds canvas, reduce phone width first (most compressible element).
+
+Save posters to ${outputDir}/ads/posters/
+Name files: poster_{platform}_{hook}_{dimensions}.png
+
+Generate at least 4 posters:
+- 2x Instagram (1 feed 1080x1080, 1 stories 1080x1920)
+- 2x TikTok cover images (1080x1920)
+
+---
+
+Platform dimensions:
+- Instagram Feed: 1080x1080
+- Instagram Stories: 1080x1920
+- Nextdoor Spotlight: 1200x1200
+- Nextdoor Display: 1200x628
+
+Save ${outputDir}/ads/ad_manifest.json listing ALL generated ads from BOTH sets, with fields: fileName, set ("gemini" or "poster"), hook, platform, dimensions, headline.
+
+${campaignBrief}`,
+
+ "video-ad-producer": `You are the Video Ad Producer agent.
+
+Read and follow the skill instructions in skills/video-ad-producer/SKILL.md exactly.
+
+First, read these knowledge files:
+- knowledge/brand_identity.md
+- knowledge/platform_guidelines.md
+- knowledge/product_campaign.md
+
+Read the upstream outputs:
+- ${outputDir}/scripts/scripts_all.json
+- ${outputDir}/ads/ad_manifest.json
+- ${outputDir}/ads/gemini/manifest.json
+- ${outputDir}/ads/posters/manifest.json
+
+## Phone Frame Asset
+A real iPhone frame PNG is at: assets/phone.png (orange rim, dynamic island, transparent screen)
+Also available in remotion-ad/public/phone.png for use as a Remotion staticFile.
+${screenshots?.length ? `
+## App Screenshots
+Screenshot files: ${screenshots.map((p) => `${p}`).join(", ")}
+Also available in remotion-ad/public/tasks_overdue.png
+
+For phone mockup scenes: layer the screenshot BEHIND phone.png so the dynamic islands align. The phone frame has a transparent screen area.
+` : ""}
+## Video Creation Rules
+Create ONE video for EACH static ad in BOTH the Gemini and Canvas Poster sets. Read both manifests to get the full list.
+
+Each video MUST match its corresponding static ad:
+- Same hook text, same message, same CTA
+- Same tone (if the ad is moody/dark, the video is moody/dark; if clean/minimal, the video is clean/minimal)
+- The static ad image can appear as a scene in the video (e.g., the final frame)
+
+For each video:
+1. Create a Remotion composition in remotion-ad/src/ with a unique CompositionId
+2. Use the phone.png frame + screenshot for the phone reveal scene
+3. Structure: Hook text (2-3s) → Phone reveal with app screenshot (4-6s) → Social proof (2-3s) → CTA (2-3s)
+4. Render using: cd remotion-ad && npx remotion render src/index.ts CompositionId --output ../${outputDir}/video/filename.mp4
+
+Video specs:
+- Instagram Reels: 1080x1920, 15s, 30fps
+- TikTok: 1080x1920, 12s, 30fps
+
+Naming: video_{source}_{platform}_{hook}_{dimensions}.mp4
+Where source is "gemini" or "poster"
+
+Save scene_plans.json and all MP4s to: ${outputDir}/video/
+
+${campaignBrief}`,
+
+ "copywriter-agent": `You are the Copywriter agent.
+
+Read and follow the skill instructions in skills/copywriter-agent/SKILL.md exactly.
+
+First, read these knowledge files:
+- knowledge/brand_identity.md
+- knowledge/platform_guidelines.md
+- knowledge/product_campaign.md
+
+Read the upstream outputs:
+- ${outputDir}/research_results.json
+- ${outputDir}/scripts/scripts_all.json
+- ${outputDir}/ads/ad_manifest.json
+
+Write platform-tuned captions following the Hook → Value → CTA → Hashtags structure.
+
+Save outputs to:
+- ${outputDir}/copy/instagram_captions.json
+- ${outputDir}/copy/tiktok_captions.json
+- ${outputDir}/copy/nextdoor_posts.json
+- ${outputDir}/copy/copy_matrix.json (maps captions to creatives)
+
+${campaignBrief}`,
+
+ "distribution-agent": `You are the Distribution Agent.
+
+Read and follow the skill instructions in skills/distribution-agent/SKILL.md exactly.
+
+First, read these knowledge files:
+- knowledge/brand_identity.md
+- knowledge/platform_guidelines.md
+
+Gather ALL outputs from the campaign:
+- ${outputDir}/ads/ (all PNG files)
+- ${outputDir}/video/ (all MP4 files)
+- ${outputDir}/copy/ (all caption JSON files)
+- ${outputDir}/scripts/ (scripts)
+
+Create a publish manifest at: ${outputDir}/Publish_manifest.md
+
+The manifest should include:
+- List of all assets with file paths
+- Recommended caption for each asset
+- Recommended posting schedule
+- Platform-specific notes
+- A review checklist
+
+IMPORTANT: Do NOT publish anything. Only create the manifest for human review.
+
+${campaignBrief}`,
+ };
+
+ return agentInstructions[agentName] || `Execute the ${agentName} skill. ${campaignBrief}`;
+}
+
+const AGENT_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes per agent
+
+const AGENT_LABELS: Record = {
+ "trend-scout": "Trend Scout",
+ "marketing-research-agent": "Research Agent",
+ "script-writer": "Script Writer",
+ "ad-creative-designer": "Ad Creative Designer",
+ "video-ad-producer": "Video Ad Producer",
+ "copywriter-agent": "Copywriter",
+ "distribution-agent": "Distribution Agent",
+};
+
+function humanizeAgentError(agentName: string, code: number | null, stderr: string): string {
+ const label = AGENT_LABELS[agentName] || agentName;
+
+ // Signal-based exits
+ if (code === 143 || code === 137) {
+ return `${label} was stopped (process terminated)`;
+ }
+ if (code === 130) {
+ return `${label} was interrupted`;
+ }
+
+ // Try to extract a meaningful error from stderr, ignoring noise
+ const meaningful = stderr
+ .split("\n")
+ .filter((line) => {
+ const l = line.trim().toLowerCase();
+ if (!l) return false;
+ if (l.includes("warning:")) return false;
+ if (l.includes("stdin")) return false;
+ if (l.includes("piping from")) return false;
+ if (l.includes("deprecation")) return false;
+ return true;
+ })
+ .slice(-3)
+ .join(" ")
+ .trim();
+
+ if (meaningful) {
+ // Cap length and clean up
+ const cleaned = meaningful.slice(0, 200).replace(/\s+/g, " ");
+ return `${label} failed: ${cleaned}`;
+ }
+
+ // Generic fallback
+ if (code !== null && code !== 0) {
+ return `${label} failed (exit code ${code})`;
+ }
+
+ return `${label} failed unexpectedly`;
+}
+
+export async function runAgentStep(
+ agentName: string,
+ prompt: string,
+ cwd: string,
+ env: Record
+): Promise<{ output: string }> {
+ return new Promise((resolve, reject) => {
+ const args = [
+ "-p",
+ prompt,
+ "--output-format",
+ "stream-json",
+ "--verbose",
+ "--allowedTools",
+ "Read,Edit,Write,Bash,Grep,Glob,mcp__nanobanana__generate_image",
+ ];
+
+ const claude = spawn("claude", args, {
+ cwd,
+ env: { ...process.env, ...env },
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+
+ let stdout = "";
+ let stderr = "";
+
+ // Timeout watchdog
+ const timeout = setTimeout(() => {
+ claude.kill("SIGTERM");
+ reject(new Error(`${agentName} timed out after ${AGENT_TIMEOUT_MS / 60000} minutes`));
+ }, AGENT_TIMEOUT_MS);
+
+ claude.stdout.on("data", (chunk: Buffer) => {
+ stdout += chunk.toString();
+ });
+
+ claude.stderr.on("data", (chunk: Buffer) => {
+ stderr += chunk.toString();
+ });
+
+ claude.on("close", (code) => {
+ clearTimeout(timeout);
+ if (code === 0) {
+ // Extract meaningful text from stream-json stdout
+ let summary = "";
+ const lines = stdout.split("\n").filter(Boolean);
+ for (const line of lines) {
+ try {
+ const event = JSON.parse(line);
+ if (event.result?.text) summary = event.result.text;
+ else if (event.type === "text" && event.text) summary += event.text;
+ } catch {
+ // skip non-JSON lines
+ }
+ }
+ resolve({ output: summary || stdout.slice(-1000) });
+ } else {
+ reject(new Error(humanizeAgentError(agentName, code, stderr)));
+ }
+ });
+
+ claude.on("error", (err) => {
+ clearTimeout(timeout);
+ reject(new Error(`Failed to start ${agentName}: ${err.message}`));
+ });
+ });
+}
+
+/**
+ * Load API keys from settings DB to pass to Claude subprocess environment.
+ */
+async function loadPipelineEnv(): Promise> {
+ const settings = await getAllSettings();
+ const env: Record = {};
+
+ if (settings.TAVILY_API_KEY) env.TAVILY_API_KEY = settings.TAVILY_API_KEY;
+ if (settings.POSTIZ_URL) env.POSTIZ_URL = settings.POSTIZ_URL;
+ if (settings.POSTIZ_API_KEY) env.POSTIZ_API_KEY = settings.POSTIZ_API_KEY;
+ if (settings.GEMINI_API_KEY) env.GEMINI_API_KEY = settings.GEMINI_API_KEY;
+ if (settings.NEXTDOOR_API_TOKEN) env.NEXTDOOR_API_TOKEN = settings.NEXTDOOR_API_TOKEN;
+ if (settings.NEXTDOOR_ADVERTISER_ID) env.NEXTDOOR_ADVERTISER_ID = settings.NEXTDOOR_ADVERTISER_ID;
+
+ return env;
+}
+
+export async function launchPipeline(
+ campaignId: string,
+ prompt: string,
+ cwd: string
+) {
+ // Load API keys from settings
+ const pipelineEnv = await loadPipelineEnv();
+
+ // Get campaign for output path and config
+ const campaignData = await prisma.campaign.findUnique({ where: { id: campaignId } });
+ const outputDir = campaignData?.outputPath || "outputs/default";
+ const campaignConfig = campaignData?.config ? JSON.parse(campaignData.config) : {};
+ const screenshots: string[] = campaignConfig.screenshots || [];
+
+ // Create output directories
+ const dirs = ["ads", "scripts", "video", "copy"];
+ for (const dir of dirs) {
+ mkdirSync(path.join(cwd, outputDir, dir), { recursive: true });
+ }
+
+ await prisma.campaign.update({
+ where: { id: campaignId },
+ data: { status: "running" },
+ });
+
+ pipelineEvents.emit(campaignId, {
+ type: "pipeline_started",
+ campaignId,
+ });
+
+ for (const agentName of AGENT_STEPS) {
+ const agentRun = await prisma.agentRun.create({
+ data: {
+ campaignId,
+ agentName,
+ status: "running",
+ startedAt: new Date(),
+ },
+ });
+
+ pipelineEvents.emit(campaignId, {
+ type: "agent_started",
+ agentName,
+ agentRunId: agentRun.id,
+ });
+
+ const startTime = Date.now();
+
+ try {
+ const agentPrompt = buildAgentPrompt(agentName, prompt, outputDir, screenshots);
+ const { output } = await runAgentStep(agentName, agentPrompt, cwd, pipelineEnv);
+ const durationMs = Date.now() - startTime;
+
+ await prisma.agentRun.update({
+ where: { id: agentRun.id },
+ data: {
+ status: "completed",
+ completedAt: new Date(),
+ durationMs,
+ outputSummary: output.slice(0, 500),
+ outputPath: outputDir,
+ },
+ });
+
+ pipelineEvents.emit(campaignId, {
+ type: "agent_completed",
+ agentName,
+ agentRunId: agentRun.id,
+ durationMs,
+ outputSummary: output.slice(0, 200),
+ });
+ } catch (error) {
+ const durationMs = Date.now() - startTime;
+ const errorMessage =
+ error instanceof Error ? error.message : "Unknown error";
+
+ await prisma.agentRun.update({
+ where: { id: agentRun.id },
+ data: {
+ status: "failed",
+ completedAt: new Date(),
+ durationMs,
+ error: errorMessage,
+ },
+ });
+
+ pipelineEvents.emit(campaignId, {
+ type: "agent_failed",
+ agentName,
+ agentRunId: agentRun.id,
+ error: errorMessage.slice(0, 200),
+ });
+
+ // Continue to next agent even if one fails
+ }
+ }
+
+ // Scan output directory for generated assets
+ if (campaignData?.outputPath) {
+ try {
+ const result = await scanOutputDirectory(campaignId, campaignData.outputPath, cwd);
+ pipelineEvents.emit(campaignId, {
+ type: "scan_complete",
+ scanned: result.scanned,
+ created: result.created,
+ });
+ } catch {
+ // Scanner errors shouldn't block pipeline completion
+ }
+ }
+
+ await prisma.campaign.update({
+ where: { id: campaignId },
+ data: { status: "review" },
+ });
+
+ pipelineEvents.emit(campaignId, {
+ type: "pipeline_complete",
+ status: "review",
+ });
+}
+
+export async function sendChatMessage(
+ sessionId: string | null,
+ message: string,
+ cwd: string
+): Promise> {
+ const pipelineEnv = await loadPipelineEnv();
+
+ const args = [
+ "-p",
+ message,
+ "--output-format",
+ "stream-json",
+ "--verbose",
+ "--allowedTools",
+ "Read,Edit,Write,Bash,Grep,Glob,mcp__nanobanana__generate_image",
+ ];
+ if (sessionId) args.push("--resume", sessionId);
+
+ const claude = spawn("claude", args, {
+ cwd,
+ env: { ...process.env, ...pipelineEnv },
+ });
+
+ const encoder = new TextEncoder();
+
+ return new ReadableStream({
+ start(controller) {
+ claude.stdout.on("data", (chunk: Buffer) => {
+ const lines = chunk.toString().split("\n").filter(Boolean);
+ for (const line of lines) {
+ try {
+ const event = JSON.parse(line);
+ // Handle various Claude CLI stream-json event types
+ let text = "";
+ if (event.type === "content_block_delta" && event.delta?.text) {
+ text = event.delta.text;
+ } else if (event.type === "assistant" && event.message) {
+ text = event.message;
+ } else if (event.type === "text" && event.text) {
+ text = event.text;
+ } else if (event.result?.text) {
+ text = event.result.text;
+ }
+
+ if (text) {
+ controller.enqueue(
+ encoder.encode(`data: ${JSON.stringify({ text })}\n\n`)
+ );
+ }
+
+ // Capture session ID if provided
+ if (event.session_id || event.sessionId) {
+ controller.enqueue(
+ encoder.encode(
+ `data: ${JSON.stringify({ sessionId: event.session_id || event.sessionId })}\n\n`
+ )
+ );
+ }
+ } catch {
+ // Non-JSON line — skip (likely progress output)
+ }
+ }
+ });
+
+ claude.on("close", () => {
+ controller.enqueue(
+ encoder.encode(`data: ${JSON.stringify({ done: true })}\n\n`)
+ );
+ controller.close();
+ });
+
+ claude.on("error", () => {
+ controller.close();
+ });
+ },
+ });
+}
diff --git a/lib/nextdoor.ts b/lib/nextdoor.ts
new file mode 100644
index 0000000..a5b6a9a
--- /dev/null
+++ b/lib/nextdoor.ts
@@ -0,0 +1,121 @@
+import { getSetting } from "./settings";
+
+const NEXTDOOR_API_URL = "https://ads.nextdoor.com/v1";
+
+async function getNextdoorConfig() {
+ const token = await getSetting("NEXTDOOR_API_TOKEN");
+ const advertiserId = await getSetting("NEXTDOOR_ADVERTISER_ID");
+ return { token, advertiserId };
+}
+
+async function nextdoorFetch(query: string, variables: Record = {}) {
+ const { token } = await getNextdoorConfig();
+ const res = await fetch(`${NEXTDOOR_API_URL}/graphql`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ query, variables }),
+ });
+
+ if (!res.ok) {
+ const text = await res.text();
+ throw new Error(`Nextdoor API error ${res.status}: ${text}`);
+ }
+
+ const data = await res.json();
+ if (data.errors?.length) {
+ throw new Error(`Nextdoor GraphQL error: ${data.errors[0].message}`);
+ }
+
+ return data.data;
+}
+
+export async function createNextdoorCampaign(
+ name: string,
+ budget: number,
+ schedule: { startDate: string; endDate: string }
+) {
+ const mutation = `
+ mutation CreateCampaign($input: CreateCampaignInput!) {
+ createCampaign(input: $input) {
+ campaign { id name status }
+ }
+ }
+ `;
+
+ return nextdoorFetch(mutation, {
+ input: {
+ advertiserId: (await getNextdoorConfig()).advertiserId,
+ name,
+ objective: "WEBSITE_CONVERSION",
+ budget: { amount: budget, currency: "USD" },
+ schedule,
+ },
+ });
+}
+
+export async function createNextdoorAdGroup(
+ campaignId: string,
+ targeting: Record
+) {
+ const mutation = `
+ mutation CreateAdGroup($input: CreateAdGroupInput!) {
+ createAdGroup(input: $input) {
+ adGroup { id name status }
+ }
+ }
+ `;
+
+ return nextdoorFetch(mutation, {
+ input: {
+ campaignId,
+ name: "Auto-generated Ad Group",
+ targeting,
+ },
+ });
+}
+
+export async function uploadNextdoorCreative(imageUrl: string) {
+ const mutation = `
+ mutation CreateCreative($input: CreateCreativeInput!) {
+ createCreative(input: $input) {
+ creative { id status }
+ }
+ }
+ `;
+
+ return nextdoorFetch(mutation, {
+ input: {
+ advertiserId: (await getNextdoorConfig()).advertiserId,
+ imageUrl,
+ type: "IMAGE",
+ },
+ });
+}
+
+export async function createNextdoorAd(
+ adGroupId: string,
+ creativeId: string,
+ copy: { headline: string; body: string; ctaText: string; destinationUrl: string }
+) {
+ const mutation = `
+ mutation CreateAd($input: CreateAdInput!) {
+ createAd(input: $input) {
+ ad { id name status }
+ }
+ }
+ `;
+
+ return nextdoorFetch(mutation, {
+ input: {
+ adGroupId,
+ creativeId,
+ headline: copy.headline,
+ body: copy.body,
+ callToAction: copy.ctaText,
+ destinationUrl: copy.destinationUrl,
+ },
+ });
+}
diff --git a/lib/postiz.ts b/lib/postiz.ts
new file mode 100644
index 0000000..f89a248
--- /dev/null
+++ b/lib/postiz.ts
@@ -0,0 +1,148 @@
+import fs from "fs";
+import path from "path";
+import { getSetting } from "./settings";
+
+const PIPELINE_ROOT = process.env.PIPELINE_ROOT || path.join(process.cwd(), "pipeline");
+
+async function getPostizConfig() {
+ const url = (await getSetting("POSTIZ_URL")) || "http://localhost:5000";
+ const apiKey = await getSetting("POSTIZ_API_KEY");
+ return { url, apiKey };
+}
+
+async function postizFetch(endpoint: string, options: RequestInit = {}) {
+ const { url, apiKey } = await getPostizConfig();
+ const res = await fetch(`${url}/public/v1${endpoint}`, {
+ ...options,
+ headers: {
+ Authorization: apiKey,
+ ...options.headers,
+ },
+ });
+
+ if (!res.ok) {
+ const text = await res.text();
+ throw new Error(`Postiz API error ${res.status}: ${text}`);
+ }
+
+ return res.json();
+}
+
+/**
+ * Resolve an asset's relative filePath to an absolute path.
+ */
+function resolveAssetPath(filePath: string): string {
+ if (path.isAbsolute(filePath)) return filePath;
+ return path.join(PIPELINE_ROOT, filePath);
+}
+
+export async function uploadToPostiz(filePath: string) {
+ const absolutePath = resolveAssetPath(filePath);
+ const fileBuffer = fs.readFileSync(absolutePath);
+ const fileName = path.basename(absolutePath);
+
+ const formData = new FormData();
+ formData.append("file", new Blob([fileBuffer]), fileName);
+
+ const { url, apiKey } = await getPostizConfig();
+ const res = await fetch(`${url}/public/v1/upload`, {
+ method: "POST",
+ headers: { Authorization: apiKey },
+ body: formData,
+ });
+
+ if (!res.ok) {
+ const text = await res.text();
+ throw new Error(`Postiz upload error ${res.status}: ${text}`);
+ }
+
+ const data = await res.json();
+ if (!data?.id || !data?.path) {
+ throw new Error(`Postiz upload returned unexpected shape: ${JSON.stringify(data)}`);
+ }
+ return { mediaId: data.id, publicUrl: data.path };
+}
+
+// Map our internal platform names to Postiz provider identifiers
+const PLATFORM_ALIASES: Record = {
+ instagram: ["instagram", "instagram-standalone", "ig"],
+ tiktok: ["tiktok", "tt"],
+ nextdoor: ["nextdoor"],
+};
+
+export async function pushToPostiz(
+ asset: {
+ filePath: string;
+ platform?: string | null;
+ metadata?: string | null;
+ },
+ scheduledAt: string
+) {
+ const { mediaId, publicUrl } = await uploadToPostiz(asset.filePath);
+
+ const integrations = await getPostizIntegrations();
+ const platform = (asset.platform || "").toLowerCase();
+ const aliases = PLATFORM_ALIASES[platform] || [platform];
+
+ const integration = integrations.find(
+ (i: { identifier?: string; providerIdentifier?: string }) => {
+ const id = (i.identifier || i.providerIdentifier || "").toLowerCase();
+ return aliases.includes(id);
+ }
+ );
+
+ if (!integration) {
+ const available = integrations
+ .map((i: { identifier?: string }) => i.identifier)
+ .join(", ");
+ throw new Error(
+ `No Postiz channel for "${platform}". Available: ${available || "none"}`
+ );
+ }
+
+ const metadata = JSON.parse(asset.metadata || "{}");
+
+ // Postiz v1 API post structure
+ const platformSettings: Record = { __type: platform };
+ if (platform === "instagram") {
+ platformSettings.post_type = "post";
+ } else if (platform === "tiktok") {
+ platformSettings.privacy_level = "PUBLIC_TO_EVERYONE";
+ platformSettings.comment = true;
+ platformSettings.duet = false;
+ platformSettings.stitch = false;
+ platformSettings.content_posting_method = "DIRECT_POST";
+ platformSettings.autoAddMusic = "no";
+ platformSettings.brand_content_toggle = false;
+ platformSettings.brand_organic_toggle = false;
+ }
+
+ const post = await postizFetch("/posts", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ type: "schedule",
+ date: scheduledAt,
+ shortLink: false,
+ tags: [],
+ posts: [
+ {
+ integration: { id: integration.id },
+ value: [
+ {
+ content: metadata.caption || "",
+ image: [{ id: mediaId, path: publicUrl }],
+ },
+ ],
+ settings: platformSettings,
+ },
+ ],
+ }),
+ });
+
+ return post;
+}
+
+export async function getPostizIntegrations() {
+ return postizFetch("/integrations");
+}
diff --git a/lib/prisma.ts b/lib/prisma.ts
new file mode 100644
index 0000000..aa1192a
--- /dev/null
+++ b/lib/prisma.ts
@@ -0,0 +1,15 @@
+import { PrismaClient } from "./generated/prisma/client";
+import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
+
+const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
+
+function createPrismaClient() {
+ const adapter = new PrismaBetterSqlite3({
+ url: process.env.DATABASE_URL || "file:./prisma/data/marketing.db",
+ });
+ return new PrismaClient({ adapter });
+}
+
+export const prisma = globalForPrisma.prisma || createPrismaClient();
+
+if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
diff --git a/lib/scanner.ts b/lib/scanner.ts
new file mode 100644
index 0000000..c0ec22b
--- /dev/null
+++ b/lib/scanner.ts
@@ -0,0 +1,184 @@
+import { readdirSync, readFileSync, statSync, existsSync } from "fs";
+import path from "path";
+import { prisma } from "./prisma";
+
+interface ScannedFile {
+ filePath: string;
+ fileName: string;
+ type: string;
+ platform: string | null;
+ format: string;
+ dimensions: string | null;
+ metadata: string | null;
+}
+
+const FORMAT_TO_TYPE: Record = {
+ png: "image",
+ jpg: "image",
+ jpeg: "image",
+ webp: "image",
+ gif: "image",
+ mp4: "video",
+ webm: "video",
+ json: "copy",
+ txt: "copy",
+ md: "research",
+ html: "research",
+};
+
+function inferPlatform(fileName: string, filePath: string): string | null {
+ const lower = (fileName + filePath).toLowerCase();
+ if (lower.includes("instagram") || lower.includes("ig_") || lower.includes("ig ")) return "instagram";
+ if (lower.includes("tiktok") || lower.includes("tt_") || lower.includes("tik_tok")) return "tiktok";
+ if (lower.includes("nextdoor") || lower.includes("nd_")) return "nextdoor";
+ return null;
+}
+
+function inferDimensions(fileName: string): string | null {
+ const match = fileName.match(/(\d{3,4})x(\d{3,4})/);
+ return match ? `${match[1]}x${match[2]}` : null;
+}
+
+function inferTypeFromPath(filePath: string, format: string): string {
+ const lower = filePath.toLowerCase();
+ if (lower.includes("/ads/")) return "image";
+ if (lower.includes("/video/")) return "video";
+ if (lower.includes("/copy/")) return "copy";
+ if (lower.includes("/scripts/")) return "script";
+ return FORMAT_TO_TYPE[format] || "research";
+}
+
+/**
+ * Try to read metadata from adjacent JSON files or manifest.
+ * For an image "instagram_feed_hook_a_1080x1080.png", look for:
+ * - "instagram_feed_hook_a_1080x1080.json" (same name, .json ext)
+ * - "ad_manifest.json" in same directory
+ * For copy JSON files, read the file itself as metadata.
+ */
+function loadMetadata(fullPath: string, format: string): string | null {
+ try {
+ // For JSON files, read the content as metadata
+ if (format === "json") {
+ const content = readFileSync(fullPath, "utf-8");
+ const parsed = JSON.parse(content);
+ // Extract caption/summary if it's an array of captions
+ if (Array.isArray(parsed)) {
+ return JSON.stringify({ captions: parsed.slice(0, 3), totalVariations: parsed.length });
+ }
+ return content.slice(0, 2000);
+ }
+
+ // For media files, look for adjacent JSON with same name
+ const jsonPath = fullPath.replace(/\.[^.]+$/, ".json");
+ if (existsSync(jsonPath)) {
+ return readFileSync(jsonPath, "utf-8").slice(0, 2000);
+ }
+
+ // Look for manifest in same directory
+ const dir = path.dirname(fullPath);
+ const manifestPath = path.join(dir, "ad_manifest.json");
+ if (existsSync(manifestPath)) {
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
+ const fileName = path.basename(fullPath);
+ // Find this file's entry in the manifest
+ if (Array.isArray(manifest)) {
+ const entry = manifest.find((e: { fileName?: string; file?: string }) =>
+ e.fileName === fileName || e.file === fileName
+ );
+ if (entry) return JSON.stringify(entry);
+ }
+ }
+
+ // Look for scene_plans.json for video files
+ if (format === "mp4" || format === "webm") {
+ const scenePlansPath = path.join(dir, "scene_plans.json");
+ if (existsSync(scenePlansPath)) {
+ const plans = JSON.parse(readFileSync(scenePlansPath, "utf-8"));
+ const fileName = path.basename(fullPath);
+ if (plans[fileName]) return JSON.stringify(plans[fileName]);
+ }
+ }
+ } catch {
+ // Metadata loading is best-effort
+ }
+
+ return null;
+}
+
+function scanDirectory(dir: string, baseDir: string): ScannedFile[] {
+ const files: ScannedFile[] = [];
+
+ try {
+ const entries = readdirSync(dir);
+ for (const entry of entries) {
+ if (entry.startsWith(".")) continue;
+
+ const fullPath = path.join(dir, entry);
+ const stat = statSync(fullPath);
+
+ if (stat.isDirectory()) {
+ files.push(...scanDirectory(fullPath, baseDir));
+ } else {
+ const ext = path.extname(entry).toLowerCase().slice(1);
+ if (!ext || ext === "gitkeep") continue;
+
+ const relativePath = path.relative(baseDir, fullPath);
+ const type = inferTypeFromPath(relativePath, ext);
+ const metadata = loadMetadata(fullPath, ext);
+
+ files.push({
+ filePath: relativePath,
+ fileName: entry,
+ type,
+ platform: inferPlatform(entry, relativePath),
+ format: ext,
+ dimensions: inferDimensions(entry),
+ metadata,
+ });
+ }
+ }
+ } catch {
+ // Directory doesn't exist or can't be read
+ }
+
+ return files;
+}
+
+export async function scanOutputDirectory(
+ campaignId: string,
+ outputPath: string,
+ pipelineRoot: string
+) {
+ const fullOutputPath = path.join(pipelineRoot, outputPath);
+ const files = scanDirectory(fullOutputPath, pipelineRoot);
+
+ let created = 0;
+
+ for (const file of files) {
+ const existing = await prisma.asset.findFirst({
+ where: {
+ campaignId,
+ filePath: file.filePath,
+ },
+ });
+
+ if (!existing) {
+ await prisma.asset.create({
+ data: {
+ campaignId,
+ type: file.type,
+ platform: file.platform,
+ format: file.format,
+ filePath: file.filePath,
+ fileName: file.fileName,
+ dimensions: file.dimensions,
+ metadata: file.metadata,
+ status: "draft",
+ },
+ });
+ created++;
+ }
+ }
+
+ return { scanned: files.length, created };
+}
diff --git a/lib/settings.ts b/lib/settings.ts
new file mode 100644
index 0000000..1f03b0d
--- /dev/null
+++ b/lib/settings.ts
@@ -0,0 +1,141 @@
+import { prisma } from "./prisma";
+
+// Settings keys and their env var fallbacks
+const SETTINGS_KEYS = {
+ // Postiz
+ POSTIZ_URL: { envVar: "POSTIZ_URL", label: "Postiz URL", placeholder: "http://localhost:5000" },
+ POSTIZ_API_KEY: { envVar: "POSTIZ_API_KEY", label: "Postiz API Key", placeholder: "your-postiz-api-key", secret: true },
+
+ // Tavily (Research)
+ TAVILY_API_KEY: { envVar: "TAVILY_API_KEY", label: "Tavily API Key", placeholder: "tvly-...", secret: true },
+
+ // Google Gemini (NanoBanana image generation)
+ GEMINI_API_KEY: { envVar: "GEMINI_API_KEY", label: "Google Gemini API Key", placeholder: "AIza...", secret: true },
+
+ // Nextdoor
+ NEXTDOOR_API_TOKEN: { envVar: "NEXTDOOR_API_TOKEN", label: "Nextdoor API Token", placeholder: "your-nextdoor-token", secret: true },
+ NEXTDOOR_ADVERTISER_ID: { envVar: "NEXTDOOR_ADVERTISER_ID", label: "Nextdoor Advertiser ID", placeholder: "your-advertiser-id" },
+} as const;
+
+export type SettingKey = keyof typeof SETTINGS_KEYS;
+
+export const SETTINGS_CONFIG = SETTINGS_KEYS;
+
+// Grouped for UI
+export const SETTINGS_GROUPS = [
+ {
+ name: "Postiz",
+ description: "Self-hosted social media scheduling. Handles Instagram and TikTok publishing.",
+ docsUrl: "https://postiz.com",
+ keys: ["POSTIZ_URL", "POSTIZ_API_KEY"] as SettingKey[],
+ },
+ {
+ name: "Tavily",
+ description: "AI-powered web research. Used by the Trend Scout and Research agents. Free tier: 1,000 searches/month.",
+ docsUrl: "https://tavily.com",
+ keys: ["TAVILY_API_KEY"] as SettingKey[],
+ },
+ {
+ 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"] as SettingKey[],
+ },
+ {
+ name: "Nextdoor",
+ description: "Direct Nextdoor Ads API integration for local advertising.",
+ docsUrl: "https://developer.nextdoor.com",
+ keys: ["NEXTDOOR_API_TOKEN", "NEXTDOOR_ADVERTISER_ID"] as SettingKey[],
+ },
+];
+
+/**
+ * Get a setting value. Checks DB first, falls back to env var.
+ */
+export async function getSetting(key: SettingKey): Promise {
+ try {
+ const setting = await prisma.setting.findUnique({ where: { key } });
+ if (setting?.value) return setting.value;
+ } catch {
+ // DB not available, fall through to env
+ }
+
+ const config = SETTINGS_KEYS[key];
+ return process.env[config.envVar] || "";
+}
+
+/**
+ * Get all settings as a map.
+ */
+export async function getAllSettings(): Promise> {
+ const result: Record = {};
+
+ for (const [key, config] of Object.entries(SETTINGS_KEYS)) {
+ try {
+ const setting = await prisma.setting.findUnique({ where: { key } });
+ result[key] = setting?.value || process.env[config.envVar] || "";
+ } catch {
+ result[key] = process.env[config.envVar] || "";
+ }
+ }
+
+ return result;
+}
+
+/**
+ * Save a setting to the database.
+ */
+export async function saveSetting(key: SettingKey, value: string) {
+ await prisma.setting.upsert({
+ where: { key },
+ update: { value },
+ create: { key, value },
+ });
+}
+
+/**
+ * Check connectivity status for each integration.
+ */
+export async function checkIntegrationStatus(): Promise> {
+ const settings = await getAllSettings();
+ const status: Record = {};
+
+ // Postiz
+ if (settings.POSTIZ_URL && settings.POSTIZ_API_KEY) {
+ try {
+ const res = await fetch(`${settings.POSTIZ_URL}/public/v1/integrations`, {
+ headers: { Authorization: `Bearer ${settings.POSTIZ_API_KEY}` },
+ signal: AbortSignal.timeout(5000),
+ });
+ status.postiz = { connected: res.ok };
+ if (!res.ok) status.postiz.error = `HTTP ${res.status}`;
+ } catch (e) {
+ status.postiz = { connected: false, error: e instanceof Error ? e.message : "Connection failed" };
+ }
+ } else {
+ status.postiz = { connected: false, error: "Not configured" };
+ }
+
+ // Tavily
+ if (settings.TAVILY_API_KEY) {
+ status.tavily = { connected: true }; // No ping endpoint, just check if key exists
+ } else {
+ status.tavily = { connected: false, error: "Not configured" };
+ }
+
+ // Gemini
+ if (settings.GEMINI_API_KEY) {
+ status.gemini = { connected: true };
+ } else {
+ status.gemini = { connected: false, error: "Not configured" };
+ }
+
+ // Nextdoor
+ if (settings.NEXTDOOR_API_TOKEN && settings.NEXTDOOR_ADVERTISER_ID) {
+ status.nextdoor = { connected: true };
+ } else {
+ status.nextdoor = { connected: false, error: "Not configured" };
+ }
+
+ return status;
+}
diff --git a/middleware.ts b/middleware.ts
new file mode 100644
index 0000000..1053846
--- /dev/null
+++ b/middleware.ts
@@ -0,0 +1,25 @@
+import { NextResponse } from "next/server";
+import type { NextRequest } from "next/server";
+
+export function middleware(request: NextRequest) {
+ const token =
+ request.cookies.get("authjs.session-token") ||
+ request.cookies.get("__Secure-authjs.session-token");
+
+ if (!token) {
+ // API routes return 401; pages redirect to login
+ if (request.nextUrl.pathname.startsWith("/api/")) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+ const loginUrl = new URL("/login", request.url);
+ return NextResponse.redirect(loginUrl);
+ }
+
+ return NextResponse.next();
+}
+
+export const config = {
+ matcher: [
+ "/((?!login|api/auth|_next/static|_next/image|favicon.ico).*)",
+ ],
+};
diff --git a/next.config.ts b/next.config.ts
index e9ffa30..68a6c64 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
- /* config options here */
+ output: "standalone",
};
export default nextConfig;
diff --git a/package-lock.json b/package-lock.json
index 31f0bba..e6d508d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,25 +8,40 @@
"name": "claude_marketing",
"version": "0.1.0",
"dependencies": {
+ "@auth/prisma-adapter": "^2.11.1",
"@base-ui/react": "^1.3.0",
+ "@prisma/adapter-better-sqlite3": "^7.5.0",
+ "@prisma/client": "^7.5.0",
+ "@tavily/core": "^0.7.2",
+ "bcryptjs": "^3.0.3",
+ "better-sqlite3": "^12.8.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.0.1",
"next": "16.2.1",
+ "next-auth": "^5.0.0-beta.30",
+ "next-themes": "^0.4.6",
+ "playwright": "^1.58.2",
+ "prisma": "^7.5.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"shadcn": "^4.1.0",
+ "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
+ "@types/bcryptjs": "^2.4.6",
+ "@types/better-sqlite3": "^7.6.13",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
+ "dotenv": "^17.3.1",
"eslint": "^9",
"eslint-config-next": "16.2.1",
"tailwindcss": "^4",
+ "tsx": "^4.21.0",
"typescript": "^5"
}
},
@@ -43,6 +58,47 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/@auth/core": {
+ "version": "0.41.1",
+ "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.1.tgz",
+ "integrity": "sha512-t9cJ2zNYAdWMacGRMT6+r4xr1uybIdmYa49calBPeTqwgAFPV/88ac9TEvCR85pvATiSPt8VaNf+Gt24JIT/uw==",
+ "license": "ISC",
+ "dependencies": {
+ "@panva/hkdf": "^1.2.1",
+ "jose": "^6.0.6",
+ "oauth4webapi": "^3.3.0",
+ "preact": "10.24.3",
+ "preact-render-to-string": "6.5.11"
+ },
+ "peerDependencies": {
+ "@simplewebauthn/browser": "^9.0.1",
+ "@simplewebauthn/server": "^9.0.2",
+ "nodemailer": "^7.0.7"
+ },
+ "peerDependenciesMeta": {
+ "@simplewebauthn/browser": {
+ "optional": true
+ },
+ "@simplewebauthn/server": {
+ "optional": true
+ },
+ "nodemailer": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@auth/prisma-adapter": {
+ "version": "2.11.1",
+ "resolved": "https://registry.npmjs.org/@auth/prisma-adapter/-/prisma-adapter-2.11.1.tgz",
+ "integrity": "sha512-Ke7DXP0Fy0Mlmjz/ZJLXwQash2UkA4621xCM0rMtEczr1kppLc/njCbUkHkIQ/PnmILjqSPEKeTjDPsYruvkug==",
+ "license": "ISC",
+ "dependencies": {
+ "@auth/core": "0.41.1"
+ },
+ "peerDependencies": {
+ "@prisma/client": ">=2.26.0 || >=3 || >=4 || >=5 || >=6"
+ }
+ },
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -510,6 +566,39 @@
}
}
},
+ "node_modules/@chevrotain/cst-dts-gen": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-10.5.0.tgz",
+ "integrity": "sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@chevrotain/gast": "10.5.0",
+ "@chevrotain/types": "10.5.0",
+ "lodash": "4.17.21"
+ }
+ },
+ "node_modules/@chevrotain/gast": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-10.5.0.tgz",
+ "integrity": "sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@chevrotain/types": "10.5.0",
+ "lodash": "4.17.21"
+ }
+ },
+ "node_modules/@chevrotain/types": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-10.5.0.tgz",
+ "integrity": "sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@chevrotain/utils": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-10.5.0.tgz",
+ "integrity": "sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==",
+ "license": "Apache-2.0"
+ },
"node_modules/@dotenvx/dotenvx": {
"version": "1.57.2",
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.57.2.tgz",
@@ -707,6 +796,33 @@
"@noble/ciphers": "^1.0.0"
}
},
+ "node_modules/@electric-sql/pglite": {
+ "version": "0.3.15",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz",
+ "integrity": "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@electric-sql/pglite-socket": {
+ "version": "0.0.20",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.0.20.tgz",
+ "integrity": "sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg==",
+ "license": "Apache-2.0",
+ "bin": {
+ "pglite-server": "dist/scripts/server.js"
+ },
+ "peerDependencies": {
+ "@electric-sql/pglite": "0.3.15"
+ }
+ },
+ "node_modules/@electric-sql/pglite-tools": {
+ "version": "0.2.20",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.2.20.tgz",
+ "integrity": "sha512-BK50ZnYa3IG7ztXhtgYf0Q7zijV32Iw1cYS8C+ThdQlwx12V5VZ9KRJ42y82Hyb4PkTxZQklVQA9JHyUlex33A==",
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "@electric-sql/pglite": "0.3.15"
+ }
+ },
"node_modules/@emnapi/core": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
@@ -740,6 +856,448 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
+ "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
+ "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
+ "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
+ "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
+ "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
+ "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
+ "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
+ "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
+ "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
+ "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
+ "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
+ "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
+ "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
+ "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
+ "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
+ "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
+ "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
+ "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
+ "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
+ "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
+ "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
+ "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
+ "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
+ "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
+ "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
+ "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@eslint-community/eslint-utils": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
@@ -1642,6 +2200,19 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
+ "node_modules/@mrleebo/prisma-ast": {
+ "version": "0.13.1",
+ "resolved": "https://registry.npmjs.org/@mrleebo/prisma-ast/-/prisma-ast-0.13.1.tgz",
+ "integrity": "sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==",
+ "license": "MIT",
+ "dependencies": {
+ "chevrotain": "^10.5.0",
+ "lilconfig": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
"node_modules/@mswjs/interceptors": {
"version": "0.41.3",
"resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz",
@@ -1922,6 +2493,212 @@
"integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==",
"license": "MIT"
},
+ "node_modules/@panva/hkdf": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
+ "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
+ "node_modules/@prisma/adapter-better-sqlite3": {
+ "version": "7.5.0",
+ "resolved": "https://registry.npmjs.org/@prisma/adapter-better-sqlite3/-/adapter-better-sqlite3-7.5.0.tgz",
+ "integrity": "sha512-ThP6y1cAZW/BdHuuTKzO+j8vzEzXDMZaDPmboJyrkdbJvO9LRiHdnG5LNKAht8YYwjHgQoq7G7NtKbaW7NebVQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/driver-adapter-utils": "7.5.0",
+ "better-sqlite3": "^12.6.0"
+ }
+ },
+ "node_modules/@prisma/client": {
+ "version": "7.5.0",
+ "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.5.0.tgz",
+ "integrity": "sha512-h4hF9ctp+kSRs7ENHGsFQmHAgHcfkOCxbYt6Ti9Xi8x7D+kP4tTi9x51UKmiTH/OqdyJAO+8V+r+JA5AWdav7w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/client-runtime-utils": "7.5.0"
+ },
+ "engines": {
+ "node": "^20.19 || ^22.12 || >=24.0"
+ },
+ "peerDependencies": {
+ "prisma": "*",
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "prisma": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@prisma/client-runtime-utils": {
+ "version": "7.5.0",
+ "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.5.0.tgz",
+ "integrity": "sha512-KnJ2b4Si/pcWEtK68uM+h0h1oh80CZt2suhLTVuLaSKg4n58Q9jBF/A42Kw6Ma+aThy1yAhfDeTC0JvEmeZnFQ==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@prisma/config": {
+ "version": "7.5.0",
+ "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.5.0.tgz",
+ "integrity": "sha512-1J/9YEX7A889xM46PYg9e8VAuSL1IUmXJW3tEhMv7XQHDWlfC9YSkIw9sTYRaq5GswGlxZ+GnnyiNsUZ9JJhSQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "c12": "3.1.0",
+ "deepmerge-ts": "7.1.5",
+ "effect": "3.18.4",
+ "empathic": "2.0.0"
+ }
+ },
+ "node_modules/@prisma/debug": {
+ "version": "7.5.0",
+ "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.5.0.tgz",
+ "integrity": "sha512-163+nffny0JoPEkDhfNco0vcuT3ymIJc9+WX7MHSQhfkeKUmKe9/wqvGk5SjppT93DtBjVwr5HPJYlXbzm6qtg==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@prisma/dev": {
+ "version": "0.20.0",
+ "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.20.0.tgz",
+ "integrity": "sha512-ovlBYwWor0OzG+yH4J3Ot+AneD818BttLA+Ii7wjbcLHUrnC4tbUPVGyNd3c/+71KETPKZfjhkTSpdS15dmXNQ==",
+ "license": "ISC",
+ "dependencies": {
+ "@electric-sql/pglite": "0.3.15",
+ "@electric-sql/pglite-socket": "0.0.20",
+ "@electric-sql/pglite-tools": "0.2.20",
+ "@hono/node-server": "1.19.9",
+ "@mrleebo/prisma-ast": "0.13.1",
+ "@prisma/get-platform": "7.2.0",
+ "@prisma/query-plan-executor": "7.2.0",
+ "foreground-child": "3.3.1",
+ "get-port-please": "3.2.0",
+ "hono": "4.11.4",
+ "http-status-codes": "2.3.0",
+ "pathe": "2.0.3",
+ "proper-lockfile": "4.1.2",
+ "remeda": "2.33.4",
+ "std-env": "3.10.0",
+ "valibot": "1.2.0",
+ "zeptomatch": "2.1.0"
+ }
+ },
+ "node_modules/@prisma/dev/node_modules/@hono/node-server": {
+ "version": "1.19.9",
+ "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz",
+ "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.14.1"
+ },
+ "peerDependencies": {
+ "hono": "^4"
+ }
+ },
+ "node_modules/@prisma/dev/node_modules/hono": {
+ "version": "4.11.4",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz",
+ "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.9.0"
+ }
+ },
+ "node_modules/@prisma/driver-adapter-utils": {
+ "version": "7.5.0",
+ "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.5.0.tgz",
+ "integrity": "sha512-B79N/amgV677mFesFDBAdrW0OIaqawap9E0sjgLBtzIz2R3hIMS1QB8mLZuUEiS4q5Y8Oh3I25Kw4SLxMypk9Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "7.5.0"
+ }
+ },
+ "node_modules/@prisma/engines": {
+ "version": "7.5.0",
+ "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.5.0.tgz",
+ "integrity": "sha512-ondGRhzoaVpRWvFaQ5wH5zS1BIbhzbKqczKjCn6j3L0Zfe/LInjcEg8+xtB49AuZBX30qyx1ZtGoootUohz2pw==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "7.5.0",
+ "@prisma/engines-version": "7.5.0-15.280c870be64f457428992c43c1f6d557fab6e29e",
+ "@prisma/fetch-engine": "7.5.0",
+ "@prisma/get-platform": "7.5.0"
+ }
+ },
+ "node_modules/@prisma/engines-version": {
+ "version": "7.5.0-15.280c870be64f457428992c43c1f6d557fab6e29e",
+ "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.5.0-15.280c870be64f457428992c43c1f6d557fab6e29e.tgz",
+ "integrity": "sha512-E+iRV/vbJLl8iGjVr6g/TEWokA+gjkV/doZkaQN1i/ULVdDwGnPJDfLUIFGS3BVwlG/m6L8T4x1x5isl8hGMxA==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@prisma/engines/node_modules/@prisma/get-platform": {
+ "version": "7.5.0",
+ "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.5.0.tgz",
+ "integrity": "sha512-7I+2y1nu/gkEKSiHHbcZ1HPe/euGdEqJZxEEMT0246q4De1+hla0ZzlTgvaT9dHcVCgLSuCG8v39db5qUUWNgw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "7.5.0"
+ }
+ },
+ "node_modules/@prisma/fetch-engine": {
+ "version": "7.5.0",
+ "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.5.0.tgz",
+ "integrity": "sha512-kZCl2FV54qnyrVdnII8MI6qvt7HfU6Cbiz8dZ8PXz4f4lbSw45jEB9/gEMK2SGdiNhBKyk/Wv95uthoLhGMLYA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "7.5.0",
+ "@prisma/engines-version": "7.5.0-15.280c870be64f457428992c43c1f6d557fab6e29e",
+ "@prisma/get-platform": "7.5.0"
+ }
+ },
+ "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": {
+ "version": "7.5.0",
+ "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.5.0.tgz",
+ "integrity": "sha512-7I+2y1nu/gkEKSiHHbcZ1HPe/euGdEqJZxEEMT0246q4De1+hla0ZzlTgvaT9dHcVCgLSuCG8v39db5qUUWNgw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "7.5.0"
+ }
+ },
+ "node_modules/@prisma/get-platform": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz",
+ "integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "7.2.0"
+ }
+ },
+ "node_modules/@prisma/get-platform/node_modules/@prisma/debug": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz",
+ "integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@prisma/query-plan-executor": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz",
+ "integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@prisma/studio-core": {
+ "version": "0.21.1",
+ "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.21.1.tgz",
+ "integrity": "sha512-bOGqG/eMQtKC0XVvcVLRmhWWzm/I+0QUWqAEhEBtetpuS3k3V4IWqKGUONkAIT223DNXJMxMtZp36b1FmcdPeg==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19 || ^22.12 || ^24.0",
+ "pnpm": "8"
+ },
+ "peerDependencies": {
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -1947,6 +2724,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "license": "MIT"
+ },
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -2227,6 +3010,17 @@
"tailwindcss": "4.2.2"
}
},
+ "node_modules/@tavily/core": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/@tavily/core/-/core-0.7.2.tgz",
+ "integrity": "sha512-N9xfw9miPD1jyVKYTMWV1hQvWPNjATT9Hffr6tv7VMHzwOPOeBwfX/R25ZE2F7meTyq6xSeGxclWnLVH2xHqFA==",
+ "license": "MIT",
+ "dependencies": {
+ "axios": "^1.7.7",
+ "https-proxy-agent": "^7.0.6",
+ "js-tiktoken": "^1.0.14"
+ }
+ },
"node_modules/@ts-morph/common": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz",
@@ -2313,6 +3107,23 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@types/bcryptjs": {
+ "version": "2.4.6",
+ "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
+ "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/better-sqlite3": {
+ "version": "7.6.13",
+ "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
+ "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -2348,7 +3159,6 @@
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
- "devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -3273,6 +4083,12 @@
"node": ">= 0.4"
}
},
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "license": "MIT"
+ },
"node_modules/available-typed-arrays": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -3289,6 +4105,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/aws-ssl-profiles": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
+ "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
"node_modules/axe-core": {
"version": "4.11.1",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz",
@@ -3299,6 +4124,17 @@
"node": ">=4"
}
},
+ "node_modules/axios": {
+ "version": "1.13.6",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
+ "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.15.11",
+ "form-data": "^4.0.5",
+ "proxy-from-env": "^1.1.0"
+ }
+ },
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@@ -3316,6 +4152,26 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/baseline-browser-mapping": {
"version": "2.10.10",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.10.tgz",
@@ -3328,6 +4184,49 @@
"node": ">=6.0.0"
}
},
+ "node_modules/bcryptjs": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
+ "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
+ "license": "BSD-3-Clause",
+ "bin": {
+ "bcrypt": "bin/bcrypt"
+ }
+ },
+ "node_modules/better-sqlite3": {
+ "version": "12.8.0",
+ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz",
+ "integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "bindings": "^1.5.0",
+ "prebuild-install": "^7.1.1"
+ },
+ "engines": {
+ "node": "20.x || 22.x || 23.x || 24.x || 25.x"
+ }
+ },
+ "node_modules/bindings": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
+ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
+ "license": "MIT",
+ "dependencies": {
+ "file-uri-to-path": "1.0.0"
+ }
+ },
+ "node_modules/bl": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
+ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer": "^5.5.0",
+ "inherits": "^2.0.4",
+ "readable-stream": "^3.4.0"
+ }
+ },
"node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
@@ -3408,6 +4307,30 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
+ "node_modules/buffer": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
+ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.1.13"
+ }
+ },
"node_modules/bundle-name": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
@@ -3432,6 +4355,46 @@
"node": ">= 0.8"
}
},
+ "node_modules/c12": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
+ "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==",
+ "license": "MIT",
+ "dependencies": {
+ "chokidar": "^4.0.3",
+ "confbox": "^0.2.2",
+ "defu": "^6.1.4",
+ "dotenv": "^16.6.1",
+ "exsolve": "^1.0.7",
+ "giget": "^2.0.0",
+ "jiti": "^2.4.2",
+ "ohash": "^2.0.11",
+ "pathe": "^2.0.3",
+ "perfect-debounce": "^1.0.0",
+ "pkg-types": "^2.2.0",
+ "rc9": "^2.1.2"
+ },
+ "peerDependencies": {
+ "magicast": "^0.3.5"
+ },
+ "peerDependenciesMeta": {
+ "magicast": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/c12/node_modules/dotenv": {
+ "version": "16.6.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
"node_modules/call-bind": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
@@ -3526,6 +4489,50 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
+ "node_modules/chevrotain": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-10.5.0.tgz",
+ "integrity": "sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@chevrotain/cst-dts-gen": "10.5.0",
+ "@chevrotain/gast": "10.5.0",
+ "@chevrotain/types": "10.5.0",
+ "@chevrotain/utils": "10.5.0",
+ "lodash": "4.17.21",
+ "regexp-to-ast": "0.5.0"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
+ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
+ "license": "MIT",
+ "dependencies": {
+ "readdirp": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 14.16.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "license": "ISC"
+ },
+ "node_modules/citty": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
+ "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
+ "license": "MIT",
+ "dependencies": {
+ "consola": "^3.2.3"
+ }
+ },
"node_modules/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
@@ -3685,6 +4692,18 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/commander": {
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
@@ -3701,6 +4720,21 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/confbox": {
+ "version": "0.2.4",
+ "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
+ "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
+ "license": "MIT"
+ },
+ "node_modules/consola": {
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
+ "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
+ "license": "MIT",
+ "engines": {
+ "node": "^14.18.0 || >=16.10.0"
+ }
+ },
"node_modules/content-disposition": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
@@ -3820,7 +4854,6 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "devOptional": true,
"license": "MIT"
},
"node_modules/damerau-levenshtein": {
@@ -3910,6 +4943,21 @@
}
}
},
+ "node_modules/decompress-response": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
+ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "mimic-response": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/dedent": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
@@ -3924,6 +4972,15 @@
}
}
},
+ "node_modules/deep-extend": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
+ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -3940,6 +4997,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/deepmerge-ts": {
+ "version": "7.1.5",
+ "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
+ "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
"node_modules/default-browser": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz",
@@ -4016,6 +5082,30 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/defu": {
+ "version": "6.1.4",
+ "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
+ "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
+ "license": "MIT"
+ },
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/denque": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
+ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -4025,11 +5115,16 @@
"node": ">= 0.8"
}
},
+ "node_modules/destr": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
+ "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
+ "license": "MIT"
+ },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
- "devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@@ -4106,6 +5201,16 @@
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
+ "node_modules/effect": {
+ "version": "3.18.4",
+ "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz",
+ "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==",
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "fast-check": "^3.23.1"
+ }
+ },
"node_modules/electron-to-chromium": {
"version": "1.5.321",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz",
@@ -4119,6 +5224,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/empathic": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
+ "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ }
+ },
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
@@ -4128,6 +5242,15 @@
"node": ">= 0.8"
}
},
+ "node_modules/end-of-stream": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+ "license": "MIT",
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
"node_modules/enhanced-resolve": {
"version": "5.20.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
@@ -4292,7 +5415,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -4335,6 +5457,48 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/esbuild": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
+ "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.4",
+ "@esbuild/android-arm": "0.27.4",
+ "@esbuild/android-arm64": "0.27.4",
+ "@esbuild/android-x64": "0.27.4",
+ "@esbuild/darwin-arm64": "0.27.4",
+ "@esbuild/darwin-x64": "0.27.4",
+ "@esbuild/freebsd-arm64": "0.27.4",
+ "@esbuild/freebsd-x64": "0.27.4",
+ "@esbuild/linux-arm": "0.27.4",
+ "@esbuild/linux-arm64": "0.27.4",
+ "@esbuild/linux-ia32": "0.27.4",
+ "@esbuild/linux-loong64": "0.27.4",
+ "@esbuild/linux-mips64el": "0.27.4",
+ "@esbuild/linux-ppc64": "0.27.4",
+ "@esbuild/linux-riscv64": "0.27.4",
+ "@esbuild/linux-s390x": "0.27.4",
+ "@esbuild/linux-x64": "0.27.4",
+ "@esbuild/netbsd-arm64": "0.27.4",
+ "@esbuild/netbsd-x64": "0.27.4",
+ "@esbuild/openbsd-arm64": "0.27.4",
+ "@esbuild/openbsd-x64": "0.27.4",
+ "@esbuild/openharmony-arm64": "0.27.4",
+ "@esbuild/sunos-x64": "0.27.4",
+ "@esbuild/win32-arm64": "0.27.4",
+ "@esbuild/win32-ia32": "0.27.4",
+ "@esbuild/win32-x64": "0.27.4"
+ }
+ },
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -4862,6 +6026,15 @@
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
+ "node_modules/expand-template": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
+ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
+ "license": "(MIT OR WTFPL)",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
@@ -4923,6 +6096,34 @@
"express": ">= 4.11"
}
},
+ "node_modules/exsolve": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
+ "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
+ "license": "MIT"
+ },
+ "node_modules/fast-check": {
+ "version": "3.23.2",
+ "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
+ "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/dubzzz"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fast-check"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "pure-rand": "^6.1.0"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -5049,6 +6250,12 @@
"node": ">=16.0.0"
}
},
+ "node_modules/file-uri-to-path": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
+ "license": "MIT"
+ },
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -5120,6 +6327,26 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/follow-redirects": {
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
+ "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -5136,6 +6363,59 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/foreground-child": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
+ "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
+ "license": "ISC",
+ "dependencies": {
+ "cross-spawn": "^7.0.6",
+ "signal-exit": "^4.0.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/form-data/node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/form-data/node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
@@ -5166,6 +6446,12 @@
"node": ">= 0.8"
}
},
+ "node_modules/fs-constants": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
+ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
+ "license": "MIT"
+ },
"node_modules/fs-extra": {
"version": "11.3.4",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz",
@@ -5180,6 +6466,21 @@
"node": ">=14.14"
}
},
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -5226,6 +6527,15 @@
"integrity": "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==",
"license": "MIT"
},
+ "node_modules/generate-function": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
+ "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "is-property": "^1.0.2"
+ }
+ },
"node_modules/generator-function": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
@@ -5302,6 +6612,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/get-port-please": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz",
+ "integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==",
+ "license": "MIT"
+ },
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
@@ -5362,6 +6678,29 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
+ "node_modules/giget": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
+ "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==",
+ "license": "MIT",
+ "dependencies": {
+ "citty": "^0.1.6",
+ "consola": "^3.4.0",
+ "defu": "^6.1.4",
+ "node-fetch-native": "^1.6.6",
+ "nypm": "^0.6.0",
+ "pathe": "^2.0.3"
+ },
+ "bin": {
+ "giget": "dist/cli.mjs"
+ }
+ },
+ "node_modules/github-from-package": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
+ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
+ "license": "MIT"
+ },
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -5423,6 +6762,18 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
+ "node_modules/grammex": {
+ "version": "3.1.12",
+ "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz",
+ "integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==",
+ "license": "MIT"
+ },
+ "node_modules/graphmatch": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.1.tgz",
+ "integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==",
+ "license": "MIT"
+ },
"node_modules/graphql": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.1.tgz",
@@ -5500,7 +6851,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@@ -5576,6 +6926,12 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/http-status-codes": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz",
+ "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==",
+ "license": "MIT"
+ },
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
@@ -5614,6 +6970,26 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -5655,6 +7031,12 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
+ "node_modules/ini": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+ "license": "ISC"
+ },
"node_modules/internal-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -6063,6 +7445,12 @@
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
+ "node_modules/is-property": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
+ "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
+ "license": "MIT"
+ },
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -6294,7 +7682,6 @@
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
- "dev": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
@@ -6309,6 +7696,15 @@
"url": "https://github.com/sponsors/panva"
}
},
+ "node_modules/js-tiktoken": {
+ "version": "1.0.21",
+ "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz",
+ "integrity": "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==",
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.5.1"
+ }
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -6726,6 +8122,15 @@
"url": "https://opencollective.com/parcel"
}
},
+ "node_modules/lilconfig": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
+ "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -6748,6 +8153,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "license": "MIT"
+ },
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -6795,6 +8206,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/long": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
+ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
+ "license": "Apache-2.0"
+ },
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -6817,6 +8234,21 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/lru.min": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
+ "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==",
+ "license": "MIT",
+ "engines": {
+ "bun": ">=1.0.0",
+ "deno": ">=1.30.0",
+ "node": ">=8.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wellwelwel"
+ }
+ },
"node_modules/lucide-react": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.0.1.tgz",
@@ -6940,6 +8372,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/mimic-response": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
+ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
@@ -6962,6 +8406,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/mkdirp-classic": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
+ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
+ "license": "MIT"
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -7034,6 +8484,38 @@
"node": "^18.17.0 || >=20.5.0"
}
},
+ "node_modules/mysql2": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz",
+ "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==",
+ "license": "MIT",
+ "dependencies": {
+ "aws-ssl-profiles": "^1.1.1",
+ "denque": "^2.1.0",
+ "generate-function": "^2.3.1",
+ "iconv-lite": "^0.7.0",
+ "long": "^5.2.1",
+ "lru.min": "^1.0.0",
+ "named-placeholders": "^1.1.3",
+ "seq-queue": "^0.0.5",
+ "sqlstring": "^2.3.2"
+ },
+ "engines": {
+ "node": ">= 8.0"
+ }
+ },
+ "node_modules/named-placeholders": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
+ "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
+ "license": "MIT",
+ "dependencies": {
+ "lru.min": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -7052,6 +8534,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
+ "node_modules/napi-build-utils": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
+ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
+ "license": "MIT"
+ },
"node_modules/napi-postinstall": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz",
@@ -7137,6 +8625,72 @@
}
}
},
+ "node_modules/next-auth": {
+ "version": "5.0.0-beta.30",
+ "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.30.tgz",
+ "integrity": "sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg==",
+ "license": "ISC",
+ "dependencies": {
+ "@auth/core": "0.41.0"
+ },
+ "peerDependencies": {
+ "@simplewebauthn/browser": "^9.0.1",
+ "@simplewebauthn/server": "^9.0.2",
+ "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0",
+ "nodemailer": "^7.0.7",
+ "react": "^18.2.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@simplewebauthn/browser": {
+ "optional": true
+ },
+ "@simplewebauthn/server": {
+ "optional": true
+ },
+ "nodemailer": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/next-auth/node_modules/@auth/core": {
+ "version": "0.41.0",
+ "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.0.tgz",
+ "integrity": "sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ==",
+ "license": "ISC",
+ "dependencies": {
+ "@panva/hkdf": "^1.2.1",
+ "jose": "^6.0.6",
+ "oauth4webapi": "^3.3.0",
+ "preact": "10.24.3",
+ "preact-render-to-string": "6.5.11"
+ },
+ "peerDependencies": {
+ "@simplewebauthn/browser": "^9.0.1",
+ "@simplewebauthn/server": "^9.0.2",
+ "nodemailer": "^6.8.0"
+ },
+ "peerDependenciesMeta": {
+ "@simplewebauthn/browser": {
+ "optional": true
+ },
+ "@simplewebauthn/server": {
+ "optional": true
+ },
+ "nodemailer": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/next-themes": {
+ "version": "0.4.6",
+ "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
+ "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
+ }
+ },
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@@ -7165,6 +8719,30 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/node-abi": {
+ "version": "3.89.0",
+ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz",
+ "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==",
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/node-abi/node_modules/semver": {
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
@@ -7222,6 +8800,12 @@
"url": "https://opencollective.com/node-fetch"
}
},
+ "node_modules/node-fetch-native": {
+ "version": "1.6.7",
+ "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
+ "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
+ "license": "MIT"
+ },
"node_modules/node-releases": {
"version": "2.0.36",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
@@ -7256,6 +8840,38 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/nypm": {
+ "version": "0.6.5",
+ "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz",
+ "integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==",
+ "license": "MIT",
+ "dependencies": {
+ "citty": "^0.2.0",
+ "pathe": "^2.0.3",
+ "tinyexec": "^1.0.2"
+ },
+ "bin": {
+ "nypm": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/nypm/node_modules/citty": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.1.tgz",
+ "integrity": "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==",
+ "license": "MIT"
+ },
+ "node_modules/oauth4webapi": {
+ "version": "3.8.5",
+ "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz",
+ "integrity": "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -7386,6 +9002,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/ohash": {
+ "version": "2.0.11",
+ "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
+ "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
+ "license": "MIT"
+ },
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -7640,6 +9262,18 @@
"integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
"license": "MIT"
},
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "license": "MIT"
+ },
+ "node_modules/perfect-debounce": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
+ "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
+ "license": "MIT"
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -7667,6 +9301,61 @@
"node": ">=16.20.0"
}
},
+ "node_modules/pkg-types": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
+ "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
+ "license": "MIT",
+ "dependencies": {
+ "confbox": "^0.2.2",
+ "exsolve": "^1.0.7",
+ "pathe": "^2.0.3"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
+ "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.58.2"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
+ "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/playwright/node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -7718,6 +9407,19 @@
"node": ">=4"
}
},
+ "node_modules/postgres": {
+ "version": "3.4.7",
+ "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz",
+ "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==",
+ "license": "Unlicense",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://github.com/sponsors/porsager"
+ }
+ },
"node_modules/powershell-utils": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz",
@@ -7730,6 +9432,52 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/preact": {
+ "version": "10.24.3",
+ "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
+ "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/preact"
+ }
+ },
+ "node_modules/preact-render-to-string": {
+ "version": "6.5.11",
+ "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz",
+ "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "preact": ">=10"
+ }
+ },
+ "node_modules/prebuild-install": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
+ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
+ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
+ "license": "MIT",
+ "dependencies": {
+ "detect-libc": "^2.0.0",
+ "expand-template": "^2.0.3",
+ "github-from-package": "0.0.0",
+ "minimist": "^1.2.3",
+ "mkdirp-classic": "^0.5.3",
+ "napi-build-utils": "^2.0.0",
+ "node-abi": "^3.3.0",
+ "pump": "^3.0.0",
+ "rc": "^1.2.7",
+ "simple-get": "^4.0.0",
+ "tar-fs": "^2.0.0",
+ "tunnel-agent": "^0.6.0"
+ },
+ "bin": {
+ "prebuild-install": "bin.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -7755,6 +9503,39 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/prisma": {
+ "version": "7.5.0",
+ "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.5.0.tgz",
+ "integrity": "sha512-n30qZpWehaYQzigLjmuPisyEsvOzHt7bZeRyg8gZ5DvJo9FGjD+gNaY59Ns3hlLD5/jZH5GBeftIss0jDbUoLg==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/config": "7.5.0",
+ "@prisma/dev": "0.20.0",
+ "@prisma/engines": "7.5.0",
+ "@prisma/studio-core": "0.21.1",
+ "mysql2": "3.15.3",
+ "postgres": "3.4.7"
+ },
+ "bin": {
+ "prisma": "build/index.js"
+ },
+ "engines": {
+ "node": "^20.19 || ^22.12 || >=24.0"
+ },
+ "peerDependencies": {
+ "better-sqlite3": ">=9.0.0",
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "better-sqlite3": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
"node_modules/prompts": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
@@ -7789,6 +9570,23 @@
"react-is": "^16.13.1"
}
},
+ "node_modules/proper-lockfile": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
+ "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "retry": "^0.12.0",
+ "signal-exit": "^3.0.2"
+ }
+ },
+ "node_modules/proper-lockfile/node_modules/signal-exit": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+ "license": "ISC"
+ },
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -7802,6 +9600,22 @@
"node": ">= 0.10"
}
},
+ "node_modules/proxy-from-env": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+ "license": "MIT"
+ },
+ "node_modules/pump": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
+ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
+ "license": "MIT",
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -7812,6 +9626,22 @@
"node": ">=6"
}
},
+ "node_modules/pure-rand": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
+ "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/dubzzz"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fast-check"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/qs": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
@@ -7871,6 +9701,40 @@
"node": ">= 0.10"
}
},
+ "node_modules/rc": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
+ "dependencies": {
+ "deep-extend": "^0.6.0",
+ "ini": "~1.3.0",
+ "minimist": "^1.2.0",
+ "strip-json-comments": "~2.0.1"
+ },
+ "bin": {
+ "rc": "cli.js"
+ }
+ },
+ "node_modules/rc/node_modules/strip-json-comments": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rc9": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
+ "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
+ "license": "MIT",
+ "dependencies": {
+ "defu": "^6.1.4",
+ "destr": "^2.0.3"
+ }
+ },
"node_modules/react": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
@@ -7899,6 +9763,33 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
+ "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.18.0"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
"node_modules/recast": {
"version": "0.23.11",
"resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz",
@@ -7938,6 +9829,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/regexp-to-ast": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/regexp-to-ast/-/regexp-to-ast-0.5.0.tgz",
+ "integrity": "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==",
+ "license": "MIT"
+ },
"node_modules/regexp.prototype.flags": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
@@ -7959,6 +9856,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/remeda": {
+ "version": "2.33.4",
+ "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz",
+ "integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/remeda"
+ }
+ },
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -8039,6 +9945,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/retry": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
+ "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
"node_modules/rettime": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/rettime/-/rettime-0.10.1.tgz",
@@ -8136,6 +10051,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/safe-push-apply": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
@@ -8218,6 +10153,11 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/seq-queue": {
+ "version": "0.0.5",
+ "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
+ "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="
+ },
"node_modules/serve-static": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
@@ -8551,12 +10491,67 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/simple-concat": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
+ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/simple-get": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
+ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decompress-response": "^6.0.0",
+ "once": "^1.3.1",
+ "simple-concat": "^1.0.0"
+ }
+ },
"node_modules/sisteransi": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
"license": "MIT"
},
+ "node_modules/sonner": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
+ "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
+ "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ }
+ },
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -8575,6 +10570,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/sqlstring": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
+ "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/stable-hash": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@@ -8591,6 +10595,12 @@
"node": ">= 0.8"
}
},
+ "node_modules/std-env": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "license": "MIT"
+ },
"node_modules/stdin-discarder": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz",
@@ -8623,6 +10633,15 @@
"integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==",
"license": "MIT"
},
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
"node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
@@ -8923,12 +10942,49 @@
"url": "https://opencollective.com/webpack"
}
},
+ "node_modules/tar-fs": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
+ "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "chownr": "^1.1.1",
+ "mkdirp-classic": "^0.5.2",
+ "pump": "^3.0.0",
+ "tar-stream": "^2.1.4"
+ }
+ },
+ "node_modules/tar-stream": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
+ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "bl": "^4.0.3",
+ "end-of-stream": "^1.4.1",
+ "fs-constants": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
+ "node_modules/tinyexec": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz",
+ "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -9083,6 +11139,38 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
+ "node_modules/tsx": {
+ "version": "4.21.0",
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
+ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "~0.27.0",
+ "get-tsconfig": "^4.7.5"
+ },
+ "bin": {
+ "tsx": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ }
+ },
+ "node_modules/tunnel-agent": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/tw-animate-css": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz",
@@ -9405,6 +11493,20 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
+ "node_modules/valibot": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz",
+ "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "typescript": ">=5"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
"node_modules/validate-npm-package-name": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz",
@@ -9743,6 +11845,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/zeptomatch": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz",
+ "integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==",
+ "license": "MIT",
+ "dependencies": {
+ "grammex": "^3.1.11",
+ "graphmatch": "^1.1.0"
+ }
+ },
"node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
diff --git a/package.json b/package.json
index 2ed6617..11c96c8 100644
--- a/package.json
+++ b/package.json
@@ -8,26 +8,44 @@
"start": "next start",
"lint": "eslint"
},
+ "prisma": {
+ "seed": "npx tsx prisma/seed.ts"
+ },
"dependencies": {
+ "@auth/prisma-adapter": "^2.11.1",
"@base-ui/react": "^1.3.0",
+ "@prisma/adapter-better-sqlite3": "^7.5.0",
+ "@prisma/client": "^7.5.0",
+ "@tavily/core": "^0.7.2",
+ "bcryptjs": "^3.0.3",
+ "better-sqlite3": "^12.8.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.0.1",
"next": "16.2.1",
+ "next-auth": "^5.0.0-beta.30",
+ "next-themes": "^0.4.6",
+ "playwright": "^1.58.2",
+ "prisma": "^7.5.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"shadcn": "^4.1.0",
+ "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
+ "@types/bcryptjs": "^2.4.6",
+ "@types/better-sqlite3": "^7.6.13",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
+ "dotenv": "^17.3.1",
"eslint": "^9",
"eslint-config-next": "16.2.1",
"tailwindcss": "^4",
+ "tsx": "^4.21.0",
"typescript": "^5"
}
}
diff --git a/phone.png b/phone.png
new file mode 100644
index 0000000..bef9811
Binary files /dev/null and b/phone.png differ
diff --git a/pipeline/.mcp.json b/pipeline/.mcp.json
new file mode 100644
index 0000000..f10e772
--- /dev/null
+++ b/pipeline/.mcp.json
@@ -0,0 +1,11 @@
+{
+ "mcpServers": {
+ "nanobanana": {
+ "command": "npx",
+ "args": ["-y", "@ycse/nanobanana-mcp"],
+ "env": {
+ "GOOGLE_AI_API_KEY": "AIzaSyBp6a4aEhZsakgcjn19T1aqJBrJ4NKzvUM"
+ }
+ }
+ }
+}
diff --git a/pipeline/CLAUDE.md b/pipeline/CLAUDE.md
new file mode 100644
index 0000000..55f3dec
--- /dev/null
+++ b/pipeline/CLAUDE.md
@@ -0,0 +1,107 @@
+# Marketing Content Pipeline
+
+This project implements an AI-powered Social Media Content Automation System.
+Seven specialized agents research, generate, render, and distribute marketing content.
+
+# System Architecture
+Seven agents running in sequence:
+1. **Trend Scout** — trending content monitoring via Tavily
+2. **Marketing Research Agent** — deep market research via Tavily
+3. **Script Writer** — ad scripts from research output
+4. **Ad Creative Designer** — static ads via NanoBanana MCP + Playwright
+5. **Video Ad Producer** — video ads via Remotion
+6. **Copywriter Agent** — platform-specific copy
+7. **Distribution Agent** — publish manifest creation (gate-protected)
+
+# Folder Structure
+- `assets/` — brand images, logos, product shots (mood board)
+- `knowledge/` — brand identity, platform guidelines, product/campaign info
+- `skills/` — all 7 agent skills (each has SKILL.md)
+- `outputs/` — generated content per campaign
+- `remotion-ad/` — Remotion video project with compositions
+
+# Knowledge Files (READ FIRST)
+Every agent MUST read these before generating ANY content:
+- `knowledge/brand_identity.md` — tone, voice, personality, CTA patterns
+- `knowledge/platform_guidelines.md` — per-platform specs and formatting
+- `knowledge/product_campaign.md` — product details, features, campaign direction
+
+# Available Tools
+
+## Tavily (Web Research)
+Use `@tavily/core` npm package. Write a Node.js script to run searches:
+```javascript
+import { tavily } from "@tavily/core";
+const client = tavily({ apiKey: process.env.TAVILY_API_KEY });
+const result = await client.search("query", { maxResults: 10 });
+```
+The TAVILY_API_KEY is available in the environment.
+
+## NanoBanana MCP (Image Generation)
+Available as MCP tool: `mcp__nanobanana__generate_image`
+Uses Google Gemini to generate images. Call it with a detailed prompt describing the desired image.
+The GEMINI_API_KEY is configured in .mcp.json.
+
+## Playwright (HTML to PNG)
+Use Playwright to render HTML/CSS layouts to pixel-perfect PNG screenshots:
+```javascript
+import { chromium } from "playwright";
+const browser = await chromium.launch();
+const page = await browser.newPage();
+await page.setViewportSize({ width: 1080, height: 1080 });
+await page.setContent(htmlString);
+await page.screenshot({ path: "output.png" });
+await browser.close();
+```
+
+## Remotion (Video Rendering)
+Video project is in `remotion-ad/`. Compositions are defined in `remotion-ad/src/`.
+To render a video:
+```bash
+cd remotion-ad && npx remotion render src/index.ts CompositionId --output ../outputs/campaign/video/filename.mp4
+```
+You can modify or create new compositions in `remotion-ad/src/` before rendering.
+
+# Pipeline Execution Order
+trend-scout → research → script-writer → ad-creative → video-producer → copywriter → distribution
+
+Each agent reads its SKILL.md from `skills/{agent-name}/SKILL.md` and follows it exactly.
+
+# Output Convention
+```
+outputs/{task_name}_{YYYYMMDD}/
+ ├── trend_report.json
+ ├── research_results.json
+ ├── research_brief.md
+ ├── interactive_report.html
+ ├── ads/
+ │ ├── instagram_feed_*.png (1080x1080)
+ │ ├── instagram_stories_*.png (1080x1920)
+ │ ├── nextdoor_spotlight_*.png (1200x1200)
+ │ ├── nextdoor_display_*.png (1200x628)
+ │ └── ad_manifest.json
+ ├── scripts/
+ │ ├── scripts_all.json
+ │ └── scripts_summary.md
+ ├── video/
+ │ ├── instagram_reel_*.mp4 (1080x1920)
+ │ ├── tiktok_ad_*.mp4 (1080x1920)
+ │ ├── nextdoor_video_*.mp4 (1080x1080)
+ │ └── scene_plans.json
+ ├── copy/
+ │ ├── instagram_captions.json
+ │ ├── tiktok_captions.json
+ │ ├── nextdoor_posts.json
+ │ └── copy_matrix.json
+ └── Publish_manifest.md
+```
+
+# File Naming Convention
+`{platform}_{format}_{hook_variant}_{dimensions}.{ext}`
+Example: `instagram_feed_hook_a_1080x1080.png`
+
+# Safety Rules
+- No live API posting without explicit user approval
+- Distribution agent creates a publish manifest — NEVER auto-publishes
+- All media files must exist locally before creating the manifest
+- Always save outputs to the specified output directory
diff --git a/pipeline/assets/.gitkeep b/pipeline/assets/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/pipeline/assets/icon.png b/pipeline/assets/icon.png
new file mode 100644
index 0000000..eb5c2fc
Binary files /dev/null and b/pipeline/assets/icon.png differ
diff --git a/pipeline/assets/phone.png b/pipeline/assets/phone.png
new file mode 100644
index 0000000..bef9811
Binary files /dev/null and b/pipeline/assets/phone.png differ
diff --git a/pipeline/assets/screenshots/.gitkeep b/pipeline/assets/screenshots/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/pipeline/knowledge/brand_identity.md b/pipeline/knowledge/brand_identity.md
new file mode 100644
index 0000000..2fadf43
--- /dev/null
+++ b/pipeline/knowledge/brand_identity.md
@@ -0,0 +1,44 @@
+# Brand Identity: honeyDue
+
+## 1. Brand Personality
+honeyDue is the reliable friend who keeps your home running smoothly. We're organized, warm, and empowering — helping homeowners feel in control of their property without the stress.
+
+Core traits:
+- Reliable — your home maintenance safety net
+- Warm — friendly, approachable, like a helpful neighbor
+- Empowering — you've got this, we just make it easier
+- Practical — real solutions, not fluff
+
+## 2. Tone & Voice
+| Attribute | Guidance |
+|-----------|----------|
+| Register | Casual-professional, like texting a handy friend |
+| Energy | Calm confidence, not hype |
+| Humor | Light, relatable homeowner humor |
+| Confidence| Reassuring, not pushy |
+| Length | Short, punchy sentences |
+
+Write like this: "Your HVAC filter is 3 months overdue. We caught it so you don't have to."
+Not like this: "Our application provides comprehensive home maintenance tracking capabilities."
+
+## 3. CTA Patterns
+- Always start CTAs with action verbs
+- Approved CTAs: "Download free", "Try honeyDue", "Get started", "Take control of your home"
+- Never use: "Buy now", "Limited time", "Act fast"
+
+## 4. Emoji Usage
+- Approved: 🏠 🔧 ✅ 📋 🐝 💛
+- Max per post: 3
+- Never start a caption with an emoji
+
+## 5. Hashtag Strategy
+- Primary (always include): #honeyDue #HomeMaintenance
+- Secondary (rotate): #HomeOwnerTips #FirstTimeHomeowner #PropertyManagement #HomeHacks #DIYHome
+- Never use: #Follow4Follow #Like4Like
+
+## 6. Brand Colors
+- Primary: #0079FF (bright blue)
+- Accent: #FF9400 (orange)
+- Background: warm off-white
+- Cards: clean white with rounded corners
+- Status colors: red (overdue), green (on track), blue (upcoming)
diff --git a/pipeline/knowledge/platform_guidelines.md b/pipeline/knowledge/platform_guidelines.md
new file mode 100644
index 0000000..7b9dd24
--- /dev/null
+++ b/pipeline/knowledge/platform_guidelines.md
@@ -0,0 +1,45 @@
+# Platform Guidelines
+
+## 1. Platform Overview
+| Platform | Content Type | Primary Tone | Hashtags |
+|----------|-------------|--------------|----------|
+| Instagram | Feed posts, Stories, Reels | Polished, aspirational | Required (3-5) |
+| TikTok | Short video ads | Raw, authentic, trend-driven | Required (3-5 trending) |
+| Nextdoor | Neighborhood posts, display ads | Warm, local, neighborly | None |
+
+## 2. Instagram
+### Specs
+| Format | Dimensions | Aspect Ratio |
+|--------|-----------|--------------|
+| Feed Post | 1080x1080 px | 1:1 |
+| Story/Reel | 1080x1920 px | 9:16 |
+
+### Caption Guidelines
+- Hook in first line (before "more" truncation)
+- Structure: Hook → Value → CTA → line break → Hashtags
+- Max 2200 chars, aim for 150-300
+
+## 3. TikTok
+### Specs
+| Format | Dimensions | Length |
+|--------|-----------|--------|
+| Video Ad | 1080x1920 px (9:16) | 9-15s sweet spot |
+
+### Style Rules
+- Raw, authentic feel (not polished brand ads)
+- Bold text overlays, max 6 words per frame
+- Hook in first 1-2 seconds
+- Trending sounds when possible
+
+## 4. Nextdoor
+### Specs
+| Format | Dimensions |
+|--------|-----------|
+| Spotlight Ad | 1200x1200 px |
+| Display Ad | 1200x628 px |
+
+### Style Rules
+- Warm, community-focused tone
+- No hashtags
+- Reference local/neighborhood context
+- CTA: "Learn More" or "Visit Us"
diff --git a/pipeline/knowledge/product_campaign.md b/pipeline/knowledge/product_campaign.md
new file mode 100644
index 0000000..a77c6c0
--- /dev/null
+++ b/pipeline/knowledge/product_campaign.md
@@ -0,0 +1,44 @@
+# Product & Campaign Knowledge: honeyDue
+
+## 1. Product Overview
+| Attribute | Details |
+|-----------|---------|
+| Product Name | honeyDue |
+| Category | Home Maintenance & Property Management |
+| Target Audience | Homeowners 25-65, landlords, first-time buyers |
+| Brand Positioning | The app that makes sure you never miss home maintenance again |
+| Platforms | iOS (App Store), Android (Google Play) |
+
+## 2. Key Features
+| Feature | Benefit | Proof Point |
+|---------|---------|-------------|
+| Smart Task Tracking | Never forget HVAC filters, gutter cleaning, or any recurring task | Overdue alerts with priority levels (High/Medium/Low) |
+| Multi-Property Support | Manage multiple homes from one dashboard | Property cards with task summaries at a glance |
+| Household Sharing | Share maintenance duties with family or housemates | Join via share code, assign tasks |
+| Contractor Management | Save plumber, electrician, HVAC tech contacts in one place | Link contractors to completed tasks |
+| Task Completion History | Photo-document every repair with cost tracking | Generate PDF reports for insurance or home sales |
+| Document & Warranty Storage | Store warranties, manuals, receipts | Track expiration dates, never lose a warranty |
+| Smart Reminders | Push notifications before tasks are due | Customizable frequency: weekly, monthly, seasonal, custom |
+
+## 3. Campaign Direction — Task Management Feature Launch
+- Goal: Drive app downloads on iOS and Android
+- Key message: Never miss home maintenance again. honeyDue tracks every task with smart reminders so nothing slips through the cracks.
+- Social proof: Thousands of homeowners already trust honeyDue
+- Visual direction: Clean & minimal — matches the app's bright, iOS-native design
+- Hero screenshot: Tasks screen showing overdue items (HVAC filters, trash, Roomba care) with priority badges and action buttons
+- Target: Homeowners 25-45 who forget tasks, busy parents, first-time buyers overwhelmed by upkeep
+
+## 4. Competitive Advantages
+- Beautiful, intuitive UI that feels native (not a clunky web wrapper)
+- Multi-property + household sharing (competitors are single-user)
+- Contractor management built in (competitors send you to Thumbtack)
+- Photo documentation + PDF reports (for insurance claims and home sales)
+- Free to use — no paywall for core features
+
+## 5. Competitors
+- Centriq — focuses on appliance manuals, not task management
+- HomeZada — bloated, tries to do everything, confusing UI
+- Thumbtack — finds contractors but doesn't track your maintenance schedule
+
+## 6. Available Visual Assets
+- assets/screenshots/tasks_overdue.png — Tasks screen showing overdue maintenance items with priority badges, action buttons, and completion counts
diff --git a/pipeline/outputs/.gitkeep b/pipeline/outputs/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/pipeline/package.json b/pipeline/package.json
new file mode 100644
index 0000000..be2ee9e
--- /dev/null
+++ b/pipeline/package.json
@@ -0,0 +1,7 @@
+{
+ "name": "marketing-pipeline",
+ "private": true,
+ "dependencies": {
+ "@tavily/core": "^0.7.2"
+ }
+}
diff --git a/pipeline/remotion-ad/package.json b/pipeline/remotion-ad/package.json
new file mode 100644
index 0000000..58dd334
--- /dev/null
+++ b/pipeline/remotion-ad/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "remotion-ad",
+ "private": true,
+ "scripts": {
+ "studio": "remotion studio",
+ "render": "remotion render",
+ "upgrade": "remotion upgrade"
+ },
+ "dependencies": {
+ "@remotion/cli": "^4.0.0",
+ "@remotion/google-fonts": "^4.0.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "remotion": "^4.0.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.0.0",
+ "typescript": "^5.0.0"
+ }
+}
diff --git a/pipeline/remotion-ad/src/AdComposition.tsx b/pipeline/remotion-ad/src/AdComposition.tsx
new file mode 100644
index 0000000..87b03f2
--- /dev/null
+++ b/pipeline/remotion-ad/src/AdComposition.tsx
@@ -0,0 +1,175 @@
+import {
+ AbsoluteFill,
+ interpolate,
+ spring,
+ useCurrentFrame,
+ useVideoConfig,
+} from "remotion";
+
+interface AdProps {
+ style: "polished" | "authentic" | "local";
+ hookText: string;
+ bodyText: string;
+ ctaText: string;
+ proofText: string;
+}
+
+const STYLE_CONFIG = {
+ polished: {
+ bg: "#0f0f0f",
+ text: "#ffffff",
+ accent: "#6366f1",
+ fontFamily: "Inter, sans-serif",
+ },
+ authentic: {
+ bg: "#1a1a2e",
+ text: "#eee",
+ accent: "#e94560",
+ fontFamily: "system-ui, sans-serif",
+ },
+ local: {
+ bg: "#fef9ef",
+ text: "#2d3436",
+ accent: "#00b894",
+ fontFamily: "Georgia, serif",
+ },
+};
+
+export const AdComposition: React.FC = ({
+ style,
+ hookText,
+ bodyText,
+ ctaText,
+ proofText,
+}) => {
+ const frame = useCurrentFrame();
+ const { fps, durationInFrames } = useVideoConfig();
+ const config = STYLE_CONFIG[style];
+
+ // Scene timing (in frames)
+ const hookEnd = Math.floor(durationInFrames * 0.2);
+ const bodyStart = hookEnd;
+ const bodyEnd = Math.floor(durationInFrames * 0.6);
+ const proofStart = bodyEnd;
+ const proofEnd = Math.floor(durationInFrames * 0.8);
+ const ctaStart = proofEnd;
+
+ // Animations
+ const hookOpacity = interpolate(frame, [0, 15, hookEnd - 10, hookEnd], [0, 1, 1, 0], {
+ extrapolateRight: "clamp",
+ });
+ const hookScale = spring({ frame, fps, config: { damping: 12 } });
+
+ const bodyOpacity = interpolate(
+ frame,
+ [bodyStart, bodyStart + 15, bodyEnd - 10, bodyEnd],
+ [0, 1, 1, 0],
+ { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
+ );
+
+ const proofOpacity = interpolate(
+ frame,
+ [proofStart, proofStart + 15, proofEnd - 10, proofEnd],
+ [0, 1, 1, 0],
+ { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
+ );
+
+ const ctaOpacity = interpolate(frame, [ctaStart, ctaStart + 15], [0, 1], {
+ extrapolateLeft: "clamp",
+ extrapolateRight: "clamp",
+ });
+ const ctaScale = spring({
+ frame: Math.max(0, frame - ctaStart),
+ fps,
+ config: { damping: 10, stiffness: 100 },
+ });
+
+ return (
+
+ {/* Hook */}
+
+ {hookText}
+
+
+ {/* Body */}
+
+ {bodyText}
+
+
+ {/* Proof */}
+
+ {proofText}
+
+
+ {/* CTA */}
+
+ {ctaText}
+
+
+ );
+};
diff --git a/pipeline/remotion-ad/src/HoneyDueAd.tsx b/pipeline/remotion-ad/src/HoneyDueAd.tsx
new file mode 100644
index 0000000..8e43233
--- /dev/null
+++ b/pipeline/remotion-ad/src/HoneyDueAd.tsx
@@ -0,0 +1,318 @@
+import {
+ AbsoluteFill,
+ Img,
+ interpolate,
+ spring,
+ useCurrentFrame,
+ useVideoConfig,
+ staticFile,
+} from "remotion";
+
+export interface HoneyDueAdProps {
+ platform: "instagram" | "tiktok";
+ hookText: string;
+ bodyText: string;
+ ctaText: string;
+ proofText: string;
+ screenshotSrc: string;
+}
+
+const COLORS = {
+ primary: "#0079FF",
+ accent: "#FF9400",
+ dark: "#1a1a2e",
+ light: "#f8f6f2",
+ white: "#ffffff",
+ red: "#FF3B30",
+};
+
+export const HoneyDueAd: React.FC = ({
+ platform,
+ hookText,
+ bodyText,
+ ctaText,
+ proofText,
+ screenshotSrc,
+}) => {
+ const frame = useCurrentFrame();
+ const { fps, durationInFrames, width, height } = useVideoConfig();
+
+ const isPolished = platform === "instagram";
+ const bg = isPolished ? COLORS.dark : "#0d0d0d";
+
+ // Scene boundaries
+ const hookEnd = Math.floor(durationInFrames * 0.22);
+ const phoneStart = hookEnd;
+ const phoneEnd = Math.floor(durationInFrames * 0.6);
+ const proofStart = phoneEnd;
+ const proofEnd = Math.floor(durationInFrames * 0.78);
+ const ctaStart = proofEnd;
+
+ // === HOOK SCENE ===
+ const hookOpacity = interpolate(
+ frame,
+ [0, 10, hookEnd - 8, hookEnd],
+ [0, 1, 1, 0],
+ { extrapolateRight: "clamp" }
+ );
+ const hookY = interpolate(frame, [0, 15], [40, 0], {
+ extrapolateRight: "clamp",
+ });
+
+ // === PHONE SCENE ===
+ const phoneScale = spring({
+ frame: Math.max(0, frame - phoneStart),
+ fps,
+ config: { damping: 14, stiffness: 80 },
+ });
+ const phoneOpacity = interpolate(
+ frame,
+ [phoneStart, phoneStart + 10, phoneEnd - 8, phoneEnd],
+ [0, 1, 1, 0],
+ { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
+ );
+ const bodyTextOpacity = interpolate(
+ frame,
+ [phoneStart + 20, phoneStart + 35],
+ [0, 1],
+ { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
+ );
+
+ // === PROOF SCENE ===
+ const proofOpacity = interpolate(
+ frame,
+ [proofStart, proofStart + 12, proofEnd - 8, proofEnd],
+ [0, 1, 1, 0],
+ { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
+ );
+ const proofScale = spring({
+ frame: Math.max(0, frame - proofStart),
+ fps,
+ config: { damping: 12 },
+ });
+
+ // === CTA SCENE ===
+ const ctaOpacity = interpolate(frame, [ctaStart, ctaStart + 12], [0, 1], {
+ extrapolateLeft: "clamp",
+ extrapolateRight: "clamp",
+ });
+ const ctaScale = spring({
+ frame: Math.max(0, frame - ctaStart),
+ fps,
+ config: { damping: 10, stiffness: 100 },
+ });
+ // Pulse the CTA button
+ const ctaPulse =
+ frame > ctaStart + 20
+ ? 1 + 0.03 * Math.sin((frame - ctaStart - 20) * 0.15)
+ : 1;
+
+ const phoneWidth = width * 0.55;
+ const phoneHeight = phoneWidth * 2.05;
+
+ return (
+
+ {/* Subtle gradient overlay */}
+
+
+ {/* === HOOK === */}
+
+
+ {hookText}
+
+
+
+ {/* === PHONE + BODY TEXT === */}
+
+ {/* Body text above phone */}
+
+ {bodyText}
+
+
+ {/* Phone mockup — real phone.png frame over screenshot */}
+
+ {/* Screenshot behind the frame — clipped to screen area */}
+
+
+
+ {/* Phone frame on top */}
+
+
+
+
+ {/* === PROOF === */}
+
+
+ {proofText}
+
+
+ Join thousands of homeowners
+
+
+
+ {/* === CTA === */}
+
+ {/* Brand name header */}
+
+ honeyDue
+
+
+ {/* Icon — 50% of canvas width, centered */}
+
+
+ {/* CTA button */}
+
+ {ctaText}
+
+
+
+ );
+};
diff --git a/pipeline/remotion-ad/src/Root.tsx b/pipeline/remotion-ad/src/Root.tsx
new file mode 100644
index 0000000..f1c00d6
--- /dev/null
+++ b/pipeline/remotion-ad/src/Root.tsx
@@ -0,0 +1,146 @@
+import { Composition, staticFile } from "remotion";
+import { HoneyDueAd } from "./HoneyDueAd";
+
+const SCREENSHOT = staticFile("tasks_overdue.png");
+
+// 15s @ 30fps for Instagram, 12s for TikTok
+const IG_FRAMES = 450;
+const TT_FRAMES = 360;
+
+export const RemotionRoot: React.FC = () => {
+ return (
+ <>
+ {/* === GEMINI AD VIDEOS === */}
+
+
+
+
+
+ {/* === CANVAS POSTER VIDEOS === */}
+
+
+
+
+ >
+ );
+};
diff --git a/pipeline/remotion-ad/src/index.ts b/pipeline/remotion-ad/src/index.ts
new file mode 100644
index 0000000..f31c790
--- /dev/null
+++ b/pipeline/remotion-ad/src/index.ts
@@ -0,0 +1,4 @@
+import { registerRoot } from "remotion";
+import { RemotionRoot } from "./Root";
+
+registerRoot(RemotionRoot);
diff --git a/pipeline/remotion-ad/tsconfig.json b/pipeline/remotion-ad/tsconfig.json
new file mode 100644
index 0000000..f419bd7
--- /dev/null
+++ b/pipeline/remotion-ad/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ES2022",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true
+ },
+ "include": ["src"]
+}
diff --git a/pipeline/scripts/research_agent.mjs b/pipeline/scripts/research_agent.mjs
new file mode 100644
index 0000000..1d540cc
--- /dev/null
+++ b/pipeline/scripts/research_agent.mjs
@@ -0,0 +1,70 @@
+import { tavily } from "@tavily/core";
+import { writeFileSync, mkdirSync } from "fs";
+import { join } from "path";
+
+const OUTPUT_DIR = join(
+ import.meta.dirname,
+ "..",
+ "outputs",
+ "task_management_feature_launch_20260323"
+);
+
+const queries = [
+ "home maintenance app download conversion strategies app store optimization 2026",
+ "homeowner forgot maintenance costly repair stories real examples",
+ "mobile app Instagram Reels ad creative best practices high CTR 2026",
+ "TikTok home improvement property management app ads that convert",
+ "millennial first time homeowner maintenance anxiety solutions apps",
+];
+
+async function runResearch() {
+ const client = tavily({ apiKey: process.env.TAVILY_API_KEY });
+ const results = [];
+
+ for (const query of queries) {
+ console.log(`Researching: "${query}"`);
+ try {
+ const response = await client.search(query, {
+ searchDepth: "advanced",
+ maxResults: 10,
+ });
+ results.push({
+ query,
+ timestamp: new Date().toISOString(),
+ resultCount: response.results.length,
+ results: response.results.map((r) => ({
+ title: r.title,
+ url: r.url,
+ content: r.content,
+ score: r.score,
+ })),
+ });
+ console.log(` → ${response.results.length} results`);
+ } catch (err) {
+ console.error(` ✗ Error for "${query}": ${err.message}`);
+ results.push({
+ query,
+ timestamp: new Date().toISOString(),
+ resultCount: 0,
+ results: [],
+ error: err.message,
+ });
+ }
+ }
+
+ const output = {
+ generatedAt: new Date().toISOString(),
+ agent: "marketing-research",
+ campaign: "task_management_feature_launch",
+ queryCount: queries.length,
+ queries,
+ results,
+ };
+
+ mkdirSync(OUTPUT_DIR, { recursive: true });
+ const outPath = join(OUTPUT_DIR, "research_results.json");
+ writeFileSync(outPath, JSON.stringify(output, null, 2));
+ console.log(`\nSaved: ${outPath}`);
+}
+
+runResearch();
diff --git a/pipeline/scripts/research_queries.mjs b/pipeline/scripts/research_queries.mjs
new file mode 100644
index 0000000..dfabb46
--- /dev/null
+++ b/pipeline/scripts/research_queries.mjs
@@ -0,0 +1,103 @@
+import { tavily } from "@tavily/core";
+import { writeFileSync } from "fs";
+
+const client = tavily({ apiKey: process.env.TAVILY_API_KEY });
+
+const OUTPUT_DIR = "/Users/treyt/Desktop/code/claude_marketing/pipeline/outputs/task_management_feature_launch_20260323";
+
+const queries = [
+ {
+ query_id: 1,
+ query_name: "Industry Trends & Market Landscape",
+ search_terms: "home maintenance app market trends 2025 2026 property management software homeowners smart home task tracking reminders growth",
+ options: {
+ searchDepth: "advanced",
+ maxResults: 10,
+ topic: "news",
+ days: 30,
+ excludeDomains: ["pinterest.com", "etsy.com"]
+ }
+ },
+ {
+ query_id: 2,
+ query_name: "Competitor Analysis",
+ search_terms: "Centriq app vs HomeZada vs Thumbtack home maintenance tracking app features pricing reviews marketing 2025 2026",
+ options: {
+ searchDepth: "advanced",
+ maxResults: 10,
+ topic: "general",
+ includeDomains: ["centriq.com", "homezada.com", "thumbtack.com", "techcrunch.com", "producthunt.com", "g2.com", "capterra.com"],
+ excludeDomains: ["pinterest.com"]
+ }
+ },
+ {
+ query_id: 3,
+ query_name: "Audience Pain Points & Conversations",
+ search_terms: "homeowner forgot HVAC filter change maintenance tasks overwhelmed home upkeep checklist first time homebuyer maintenance schedule busy parents household chores",
+ options: {
+ searchDepth: "advanced",
+ maxResults: 10,
+ topic: "general",
+ includeDomains: ["reddit.com", "twitter.com", "quora.com", "houzz.com"],
+ excludeDomains: ["pinterest.com"]
+ }
+ },
+ {
+ query_id: 4,
+ query_name: "High-Performing Hooks & Ad Copy",
+ search_terms: "best performing mobile app ad hooks 2025 2026 home productivity app Instagram TikTok ad copy examples high engagement task management app marketing download conversion",
+ options: {
+ searchDepth: "advanced",
+ maxResults: 10,
+ topic: "general",
+ excludeDomains: ["pinterest.com", "etsy.com"]
+ }
+ },
+ {
+ query_id: 5,
+ query_name: "Viral Content & Cultural Moments",
+ search_terms: "viral home maintenance TikTok homeowner hack spring cleaning 2026 home organization trending content adulting homeownership meme cleaning routine",
+ options: {
+ searchDepth: "advanced",
+ maxResults: 10,
+ topic: "news",
+ days: 14,
+ excludeDomains: ["pinterest.com"]
+ }
+ }
+];
+
+async function runQueries() {
+ const results = [];
+
+ for (const q of queries) {
+ console.log(`Executing query ${q.query_id}: ${q.query_name}...`);
+ try {
+ const response = await client.search(q.search_terms, q.options);
+ results.push({
+ query_id: q.query_id,
+ query_name: q.query_name,
+ search_terms: q.search_terms,
+ results_count: response.results ? response.results.length : 0,
+ results: response.results || [],
+ answer: response.answer || null
+ });
+ console.log(` -> Got ${response.results ? response.results.length : 0} results`);
+ } catch (err) {
+ console.error(` -> Error on query ${q.query_id}: ${err.message}`);
+ results.push({
+ query_id: q.query_id,
+ query_name: q.query_name,
+ search_terms: q.search_terms,
+ results_count: 0,
+ results: [],
+ error: err.message
+ });
+ }
+ }
+
+ writeFileSync(`${OUTPUT_DIR}/raw_research_results.json`, JSON.stringify(results, null, 2));
+ console.log(`\nRaw results saved to ${OUTPUT_DIR}/raw_research_results.json`);
+}
+
+runQueries();
diff --git a/pipeline/scripts/tavily_trend_search.mjs b/pipeline/scripts/tavily_trend_search.mjs
new file mode 100644
index 0000000..234b6fd
--- /dev/null
+++ b/pipeline/scripts/tavily_trend_search.mjs
@@ -0,0 +1,54 @@
+import { tavily } from "@tavily/core";
+import { writeFileSync } from "fs";
+
+const client = tavily({ apiKey: process.env.TAVILY_API_KEY });
+
+const queries = [
+ {
+ name: "trending_hooks",
+ query: "trending social media hooks productivity apps 2026 viral opening lines scroll-stopping techniques instagram tiktok",
+ options: { topic: "news", days: 7, maxResults: 10 }
+ },
+ {
+ name: "competitor_ads",
+ query: "productivity app ad campaigns 2026 Notion Todoist TickTick Any.do advertising strategy messaging",
+ options: { topic: "news", days: 7, maxResults: 10, searchDepth: "advanced" }
+ },
+ {
+ name: "viral_formats",
+ query: "viral content formats instagram reels tiktok 2026 trending templates transitions app promotion",
+ options: { topic: "news", days: 7, maxResults: 10, searchDepth: "advanced" }
+ },
+ {
+ name: "audience_pain_points",
+ query: "productivity app complaints wishlist 2026 professionals time management frustrations reddit reviews",
+ options: { topic: "news", days: 7, maxResults: 10 }
+ },
+ {
+ name: "seasonal_timely",
+ query: "upcoming events March April 2026 productivity awareness days professional development seasonal marketing moments",
+ options: { topic: "news", days: 14, maxResults: 10 }
+ }
+];
+
+async function runSearches() {
+ const results = {};
+ for (const q of queries) {
+ console.log(`Searching: ${q.name}...`);
+ try {
+ const res = await client.search(q.query, q.options);
+ results[q.name] = res.results || [];
+ console.log(` → ${results[q.name].length} results`);
+ } catch (err) {
+ console.error(` → Error: ${err.message}`);
+ results[q.name] = [];
+ }
+ }
+ writeFileSync(
+ "/Users/treyt/Desktop/code/claude_marketing/pipeline/outputs/test_campaign_e2e_20260323/raw_search_results.json",
+ JSON.stringify(results, null, 2)
+ );
+ console.log("Done. Raw results saved.");
+}
+
+runSearches();
diff --git a/pipeline/scripts/trend_scout_search.mjs b/pipeline/scripts/trend_scout_search.mjs
new file mode 100644
index 0000000..132a167
--- /dev/null
+++ b/pipeline/scripts/trend_scout_search.mjs
@@ -0,0 +1,56 @@
+import { tavily } from "@tavily/core";
+import { writeFileSync } from "fs";
+
+const client = tavily({ apiKey: process.env.TAVILY_API_KEY });
+
+async function runSearches() {
+ const searches = [
+ {
+ name: "trending_hooks",
+ query: "trending social media hooks home maintenance app 2026 viral opening lines scroll-stopping Instagram TikTok",
+ options: { maxResults: 10, topic: "news", days: 7 }
+ },
+ {
+ name: "competitor_ads",
+ query: "Centriq HomeZada Thumbtack home maintenance app ad campaign marketing 2026",
+ options: { maxResults: 10, searchDepth: "advanced", days: 14 }
+ },
+ {
+ name: "viral_formats",
+ query: "viral content formats Instagram Reels TikTok 2026 trending templates transitions video styles March",
+ options: { maxResults: 10, topic: "news", days: 7 }
+ },
+ {
+ name: "audience_pain_points",
+ query: "homeowner maintenance tasks forgotten complaints overwhelmed home upkeep HVAC filter reminders Reddit 2026",
+ options: { maxResults: 10, searchDepth: "advanced", days: 14 }
+ },
+ {
+ name: "seasonal_angles",
+ query: "spring home maintenance checklist 2026 April seasonal homeowner tasks events awareness days",
+ options: { maxResults: 10, topic: "news", days: 14 }
+ }
+ ];
+
+ const results = {};
+
+ for (const search of searches) {
+ console.log(`\nSearching: ${search.name}...`);
+ try {
+ const result = await client.search(search.query, search.options);
+ results[search.name] = result.results;
+ console.log(` Found ${result.results.length} results`);
+ } catch (err) {
+ console.error(` Error: ${err.message}`);
+ results[search.name] = [];
+ }
+ }
+
+ writeFileSync(
+ "/Users/treyt/Desktop/code/claude_marketing/pipeline/outputs/task_management_feature_launch_20260323/raw_search_results.json",
+ JSON.stringify(results, null, 2)
+ );
+ console.log("\nAll searches complete. Raw results saved.");
+}
+
+runSearches();
diff --git a/pipeline/skills/ad-creative-designer/SKILL.md b/pipeline/skills/ad-creative-designer/SKILL.md
new file mode 100644
index 0000000..59ae14a
--- /dev/null
+++ b/pipeline/skills/ad-creative-designer/SKILL.md
@@ -0,0 +1,281 @@
+---
+name: ad-creative-designer
+description: >
+ Static image ad designer agent. Generates ad creatives for Instagram (1080x1080 feed,
+ 1080x1920 stories), and Nextdoor (1200x1200 spotlight, 1200x628 display). Uses NanoBanana
+ MCP for AI image generation and Playwright for HTML-to-PNG rendering. Typography scale:
+ 72px headline, 36px subtext, 12px CTA. Outputs production-ready PNG files.
+---
+
+# Ad Creative Designer Agent
+
+## Purpose
+You are the Ad Creative Designer — the fourth agent in the pipeline. You take the scripts
+and research output and produce static image ads for Instagram and Nextdoor. You combine
+AI-generated imagery (via NanoBanana MCP) with precise HTML/CSS layouts rendered to PNG
+(via Playwright). Your ads must be visually striking, on-brand, and sized exactly to
+platform specifications.
+
+## CRITICAL — Read Knowledge Files First
+Before designing ANY ads, you MUST read these files and internalize their contents:
+
+1. `knowledge/brand_identity.md` — tone, voice, CTA patterns, emoji rules, brand personality
+2. `knowledge/platform_guidelines.md` — exact dimensions, aspect ratios, platform-specific rules
+3. `knowledge/product_campaign.md` — product details, visual direction, available assets
+
+Additionally, read the upstream outputs:
+- `outputs/{task_name}_{YYYYMMDD}/scripts/scripts_all.json` — scripts with hooks and CTAs
+- `outputs/{task_name}_{YYYYMMDD}/scripts/scripts_summary.md` — script rankings and recommendations
+- `outputs/{task_name}_{YYYYMMDD}/research_brief.md` — campaign strategy context
+
+Do NOT begin design work until all knowledge files are read. Ads without brand alignment
+will need to be redone.
+
+## Ad Configurations
+
+### Platform Dimensions
+| Platform | Format | Width | Height | Aspect Ratio | Use Case |
+|----------|--------|-------|--------|--------------|----------|
+| Instagram | Feed Post | 1080 | 1080 | 1:1 | Feed ads, carousel slides |
+| Instagram | Story/Reel | 1080 | 1920 | 9:16 | Stories, Reels cover |
+| Nextdoor | Spotlight | 1200 | 1200 | 1:1 | Spotlight ads |
+| Nextdoor | Display | 1200 | 628 | ~1.91:1 | Display/banner ads |
+
+### Typography Scale
+| Element | Font Size | Weight | Usage |
+|---------|-----------|--------|-------|
+| Headline | 72px | Bold (700) | Primary hook text |
+| Subtext | 36px | Regular (400) | Supporting value proposition |
+| CTA | 12px | Semi-bold (600) | Call-to-action button/text |
+| Fine Print | 10px | Light (300) | Legal, disclaimers (if needed) |
+
+### Color Guidelines
+Derive colors from the brand identity. Ensure:
+- Minimum 4.5:1 contrast ratio for text on backgrounds (WCAG AA)
+- CTA buttons have high contrast against the ad background
+- Consistent color palette across all ad variants
+
+## Workflow
+
+### Step 1: Plan Ad Variants
+Based on the scripts and rankings, determine which scripts to produce as static ads.
+At minimum, produce:
+- 2 Instagram feed ads (1080x1080) — top-ranked IG hooks
+- 2 Instagram story ads (1080x1920) — adapted from top IG hooks
+- 1 Nextdoor spotlight ad (1200x1200) — top-ranked Nextdoor hook
+- 1 Nextdoor display ad (1200x628) — adapted from top Nextdoor hook
+
+For each ad, document:
+- Which script/hook it is based on
+- Headline text (from hook)
+- Subtext (from body, condensed)
+- CTA text (from script CTA)
+- Visual direction (from product_campaign.md)
+- Background concept (AI-generated or brand asset)
+
+### Step 2: Generate Background Images (NanoBanana MCP)
+Use the NanoBanana MCP tool to generate background images for ads.
+
+For each ad variant:
+1. Write a detailed image generation prompt that includes:
+ - Visual style (clean, bold, minimal, vibrant — from campaign direction)
+ - Subject matter (product-related imagery)
+ - Color palette (from brand identity)
+ - Mood/atmosphere (matching the script tone)
+ - Composition notes (leave space for text overlay)
+2. Generate the image at the appropriate dimensions
+3. Review the generated image for brand alignment
+4. Re-generate if the image does not match the brief
+
+**Prompt Template:**
+```
+A [style] marketing image for a [product category] app.
+[Visual description]. [Color palette description].
+[Mood/atmosphere]. Leave clear space in [position] for text overlay.
+Dimensions: [width]x[height]. No text in the image.
+```
+
+### Step 3: Build HTML Ad Layouts
+For each ad, create an HTML file with inline CSS that:
+- Sets the exact canvas dimensions (width x height)
+- Positions the background image (generated or from assets/)
+- Overlays headline text at 72px bold
+- Adds subtext at 36px regular
+- Includes CTA at 12px semi-bold (styled as button or pill)
+- Applies brand colors and fonts
+- Uses proper text shadows or background overlays for readability
+
+**HTML Template Structure:**
+```html
+
+
+
+
+
+
+
+
+
+ {headline}
+ {subtext}
+ {cta_text}
+
+
+
+```
+
+### Step 4: Render HTML to PNG (Playwright)
+Use Playwright to screenshot each HTML ad layout to a production-ready PNG.
+
+For each HTML ad file:
+1. Launch a headless browser via Playwright
+2. Set viewport to match the ad dimensions exactly
+3. Navigate to the HTML file
+4. Wait for all images and fonts to load
+5. Take a full-page screenshot
+6. Save as PNG with a descriptive filename
+
+**Playwright Screenshot Process:**
+```
+- Set viewport: { width: {ad_width}, height: {ad_height} }
+- Navigate to HTML file
+- Wait for networkidle
+- Screenshot to: outputs/{task_name}_{date}/ads/{filename}.png
+- Verify file size is reasonable (>10KB, <5MB)
+```
+
+### Step 5: Quality Review
+For each rendered ad:
+- Verify dimensions match spec exactly
+- Check text readability (contrast ratio)
+- Confirm CTA is visible and properly styled
+- Ensure brand consistency across all variants
+- Verify no text is cut off or overlapping
+- Check that the visual hierarchy is correct (headline > subtext > CTA)
+
+### Step 6: Write Output Files
+Generate all output files and an ad manifest.
+
+## Output Convention
+
+All output goes to: `outputs/{task_name}_{YYYYMMDD}/ads/`
+
+### File Naming Convention
+```
+{platform}_{format}_{hook_number}_{variant}.png
+```
+Examples:
+- `instagram_feed_hook1_v1.png`
+- `instagram_story_hook2_v1.png`
+- `nextdoor_spotlight_hook3_v1.png`
+- `nextdoor_display_hook3_v1.png`
+
+### ad_manifest.json
+```json
+{
+ "generated_at": "ISO-8601 timestamp",
+ "campaign": "campaign name",
+ "total_ads": 6,
+ "ads": [
+ {
+ "filename": "instagram_feed_hook1_v1.png",
+ "platform": "instagram",
+ "format": "feed",
+ "dimensions": "1080x1080",
+ "hook_number": 1,
+ "headline": "headline text",
+ "subtext": "subtext",
+ "cta": "CTA text",
+ "script_source": "hook1_instagram",
+ "background_source": "nanobanana|asset",
+ "background_prompt": "prompt used for generation (if AI-generated)"
+ }
+ ]
+}
+```
+
+### HTML Source Files
+Keep the HTML source files alongside the PNGs for future editing:
+- `instagram_feed_hook1_v1.html`
+- `instagram_story_hook2_v1.html`
+- etc.
+
+## NanoBanana MCP Usage
+- Use the NanoBanana MCP tool for AI image generation
+- Always specify "no text" in the prompt — text is added via HTML overlay
+- Generate at the exact target dimensions when possible
+- If exact dimensions are not supported, generate larger and crop
+- Save generated images to `outputs/{task_name}_{date}/ads/backgrounds/`
+
+## Playwright Usage
+- Use Playwright MCP or Playwright API for HTML-to-PNG rendering
+- Set device scale factor to 1 (we specify exact pixel dimensions)
+- Use `waitForLoadState('networkidle')` before screenshots
+- Disable animations for consistent renders
+- If fonts fail to load, use system fonts as fallback
+
+## Troubleshooting
+
+| Problem | Solution |
+|---------|----------|
+| NanoBanana generates text in image | Add "no text, no letters, no words" to the prompt |
+| Playwright screenshot is wrong size | Double-check viewport dimensions match ad spec exactly |
+| Text is unreadable on background | Increase overlay opacity or add text shadow |
+| Colors do not match brand | Re-read brand_identity.md and use exact hex values |
+| Image file is too large | Optimize PNG with compression; target under 2MB per ad |
+| Fonts look different than expected | Use web-safe fonts or embed fonts in the HTML |
+| HTML layout breaks at certain sizes | Use absolute positioning with pixel values, not percentages |
+| CTA button is too small | CTA font is 12px by spec, but ensure button padding makes it tappable |
+
+## Quality Checklist
+Before finalizing your output, verify:
+
+- [ ] All three knowledge files were read before starting
+- [ ] Script and research outputs were read and used for content
+- [ ] At least 6 ad variants produced (2 IG feed + 2 IG story + 1 ND spotlight + 1 ND display)
+- [ ] All dimensions match platform specs exactly
+- [ ] Typography follows the scale: 72px headline, 36px subtext, 12px CTA
+- [ ] Text contrast ratio meets WCAG AA (4.5:1 minimum)
+- [ ] CTAs use approved text from brand_identity.md
+- [ ] Brand colors are consistent across all ads
+- [ ] No text is cut off, overlapping, or misaligned
+- [ ] Visual hierarchy is clear: headline > subtext > CTA
+- [ ] Background images are relevant and on-brand
+- [ ] PNG files are production-ready (correct dimensions, reasonable file size)
+- [ ] HTML source files are saved alongside PNGs
+- [ ] ad_manifest.json is valid JSON with all required fields
+- [ ] All output files saved to the correct directory path
diff --git a/pipeline/skills/copywriter-agent/SKILL.md b/pipeline/skills/copywriter-agent/SKILL.md
new file mode 100644
index 0000000..3b7a325
--- /dev/null
+++ b/pipeline/skills/copywriter-agent/SKILL.md
@@ -0,0 +1,279 @@
+---
+name: copywriter-agent
+description: >
+ Platform-tuned copywriter agent. Writes captions and post copy for Instagram, TikTok,
+ and Nextdoor. Follows the Hook-Value-CTA-Hashtags structure. Outputs instagram_captions.json,
+ tiktok_captions.json, and nextdoor_posts.json with multiple caption variants per platform.
+ Ensures brand voice consistency and platform-specific formatting.
+---
+
+# Copywriter Agent
+
+## Purpose
+You are the Copywriter Agent — the sixth agent in the pipeline. You write the actual
+captions, post copy, and supporting text that accompanies every ad creative and video.
+Your copy is the bridge between the visual content and the audience's action. Every word
+must be intentional, on-brand, and optimized for each platform's unique requirements.
+
+## CRITICAL — Read Knowledge Files First
+Before writing ANY copy, you MUST read these files and internalize their contents:
+
+1. `knowledge/brand_identity.md` — tone, voice, CTA patterns, emoji rules, hashtag strategy
+2. `knowledge/platform_guidelines.md` — caption guidelines, character limits, platform-specific rules
+3. `knowledge/product_campaign.md` — product details, features, campaign goals, proof points
+
+Additionally, read ALL upstream outputs:
+- `outputs/{task_name}_{YYYYMMDD}/scripts/scripts_all.json` — scripts for caption alignment
+- `outputs/{task_name}_{YYYYMMDD}/ads/ad_manifest.json` — ad variants to write captions for
+- `outputs/{task_name}_{YYYYMMDD}/video/video_manifest.json` — videos to write captions for
+- `outputs/{task_name}_{YYYYMMDD}/research_brief.md` — audience language and hooks
+
+Do NOT write any copy until all knowledge files are read. Copy that misses the brand voice
+or uses wrong CTAs will need to be rewritten.
+
+## Caption Structure
+All captions follow the **Hook - Value - CTA - Hashtags** structure:
+
+```
+HOOK
+The opening line that stops the scroll. Must work before the "...more" truncation.
+This is the most important line — treat it like a headline.
+
+VALUE
+1-3 sentences that deliver the benefit, solve the pain point, or tell the story.
+Use the audience's own language (from research). Be specific, not generic.
+
+CTA
+A clear call to action using ONLY approved CTAs from brand_identity.md:
+"Download free", "Try it now", "Get started", "See for yourself"
+
+HASHTAGS (platform-dependent)
+Line break before hashtags. Follow the hashtag strategy from brand_identity.md.
+```
+
+## Platform-Specific Rules
+
+### Instagram Captions
+- **Character limit**: 2200 max, aim for 150-300 characters
+- **Hook**: Must be compelling in the first line (before "...more" truncation)
+- **Emojis**: Max 3 per post, from approved list only, never start with emoji
+- **Hashtags**: 3-5 hashtags, always include primary hashtags, rotate secondary
+- **Structure**: Hook → Value → CTA → line break → Hashtags
+- **Tone**: Polished, aspirational, confident
+- **Line breaks**: Use for readability (Instagram preserves them)
+
+### TikTok Captions
+- **Character limit**: 4000 max, aim for 100-200 characters
+- **Hook**: Short, punchy, curiosity-driven
+- **Emojis**: Max 3, from approved list, match the energetic tone
+- **Hashtags**: 3-5 trending hashtags + brand hashtags
+- **Structure**: Hook → Value → CTA → Hashtags (compact format)
+- **Tone**: Raw, authentic, conversational
+- **Special**: Include relevant trending hashtags identified in research
+
+### Nextdoor Captions
+- **Character limit**: Platform standard, aim for 100-250 characters
+- **Hook**: Warm, community-oriented opening
+- **Emojis**: Minimal (0-1), only if it fits the neighborly tone
+- **Hashtags**: NONE — Nextdoor does not use hashtags
+- **Structure**: Hook → Value → CTA (no hashtags section)
+- **Tone**: Warm, local, neighborly
+- **CTA**: "Learn More" or "Visit Us" only
+
+## Workflow
+
+### Step 1: Map Content to Captions
+Review all ad and video assets. Create a mapping of which captions are needed:
+
+| Asset | Platform | Caption Type |
+|-------|----------|-------------|
+| instagram_feed_hook1_v1.png | Instagram | Feed post caption |
+| instagram_story_hook2_v1.png | Instagram | Story caption (shorter) |
+| instagram_hook1_polished.mp4 | Instagram | Reel caption |
+| tiktok_hook2_authentic.mp4 | TikTok | Video caption |
+| nextdoor_spotlight_hook3_v1.png | Nextdoor | Post caption |
+| nextdoor_display_hook3_v1.png | Nextdoor | Ad caption |
+
+### Step 2: Write Caption Variants
+For each asset, write 3 caption variants:
+- **Variant A**: Direct and benefit-focused
+- **Variant B**: Story-driven or question-led
+- **Variant C**: Social-proof-led or urgency-driven (using approved patterns only)
+
+This gives the distribution agent and the user options to choose from.
+
+### Step 3: Apply Platform Formatting
+For each caption variant:
+1. Apply the correct tone for the platform
+2. Format line breaks and spacing per platform norms
+3. Add hashtags per platform rules (or omit for Nextdoor)
+4. Add emojis per brand guidelines (max 3, from approved list)
+5. Check character count against platform limits
+6. Verify CTA is from the approved list
+
+### Step 4: Cross-Check Brand Alignment
+For every caption, verify:
+- Tone matches brand_identity.md (casual-professional, upbeat, confident)
+- No banned CTAs ("Buy now", "Limited time", "Act fast")
+- No banned hashtags (#Follow4Follow, #Like4Like)
+- Emoji usage within limits (max 3, from approved list)
+- No caption starts with an emoji
+- Voice is consistent across all platforms (same brand, different register)
+
+### Step 5: Write Output Files
+Generate the platform-specific JSON files and a summary document.
+
+## Output Convention
+
+All output goes to: `outputs/{task_name}_{YYYYMMDD}/copy/`
+
+### instagram_captions.json
+```json
+{
+ "generated_at": "ISO-8601 timestamp",
+ "campaign": "campaign name",
+ "platform": "instagram",
+ "total_captions": 6,
+ "captions": [
+ {
+ "caption_id": "ig_feed_hook1_varA",
+ "asset_reference": "instagram_feed_hook1_v1.png",
+ "asset_type": "feed_post",
+ "variant": "A",
+ "variant_style": "direct_benefit",
+ "hook": "the opening hook line",
+ "value": "the value proposition text",
+ "cta": "Download free",
+ "hashtags": ["#YourApp", "#YourCategory", "#ProductivityTips"],
+ "full_caption": "complete formatted caption with line breaks",
+ "character_count": 245,
+ "emoji_count": 2,
+ "emojis_used": ["✨", "🚀"]
+ }
+ ]
+}
+```
+
+### tiktok_captions.json
+```json
+{
+ "generated_at": "ISO-8601 timestamp",
+ "campaign": "campaign name",
+ "platform": "tiktok",
+ "total_captions": 3,
+ "captions": [
+ {
+ "caption_id": "tt_hook2_varA",
+ "asset_reference": "tiktok_hook2_authentic.mp4",
+ "asset_type": "video",
+ "variant": "A",
+ "variant_style": "direct_benefit",
+ "hook": "the opening hook",
+ "value": "the value text",
+ "cta": "Try it now",
+ "hashtags": ["#YourApp", "#TrendingHashtag", "#ForYou"],
+ "full_caption": "complete formatted caption",
+ "character_count": 150,
+ "emoji_count": 1,
+ "emojis_used": ["🔥"]
+ }
+ ]
+}
+```
+
+### nextdoor_posts.json
+```json
+{
+ "generated_at": "ISO-8601 timestamp",
+ "campaign": "campaign name",
+ "platform": "nextdoor",
+ "total_captions": 3,
+ "captions": [
+ {
+ "caption_id": "nd_spotlight_hook3_varA",
+ "asset_reference": "nextdoor_spotlight_hook3_v1.png",
+ "asset_type": "spotlight_ad",
+ "variant": "A",
+ "variant_style": "community_focused",
+ "hook": "the opening hook",
+ "value": "the value text",
+ "cta": "Learn More",
+ "hashtags": [],
+ "full_caption": "complete formatted caption (no hashtags)",
+ "character_count": 180,
+ "emoji_count": 0,
+ "emojis_used": []
+ }
+ ]
+}
+```
+
+### copy_summary.md
+A summary document containing:
+- Campaign context (1 paragraph)
+- Total captions written (by platform)
+- Recommended variant per asset (A, B, or C) with rationale
+- Caption themes and angles used
+- Hashtag sets used (per platform)
+- A/B testing recommendations
+- Notes for the distribution agent
+
+## Copywriting Rules
+
+### DO:
+- Use active voice exclusively
+- Start hooks with scroll-stopping statements or questions
+- Mirror the audience's language (from research findings)
+- Use specific numbers and proof points from product_campaign.md
+- Keep Instagram captions between 150-300 characters
+- Keep TikTok captions between 100-200 characters
+- Include line breaks for readability on Instagram
+- Test hooks by asking: "Would I stop scrolling for this?"
+
+### DO NOT:
+- Use passive voice
+- Start any caption with an emoji
+- Use more than 3 emojis per post
+- Use emojis not on the approved list
+- Use banned CTAs ("Buy now", "Limited time", "Act fast")
+- Use banned hashtags (#Follow4Follow, #Like4Like)
+- Include hashtags in Nextdoor captions
+- Write generic copy that could apply to any brand
+- Exceed platform character limits
+- Use jargon the target audience would not use
+
+## Troubleshooting
+
+| Problem | Solution |
+|---------|----------|
+| Ad/video manifest not found | Check outputs directory; ask user for campaign task name |
+| Captions feel generic | Use specific product features and audience language from research |
+| Character count too high | Cut filler words; use shorter synonyms; split into multiple lines |
+| Hashtags feel forced | Only use relevant hashtags; check trending tags from research |
+| Tone inconsistent across platforms | Re-read brand_identity.md; adjust register but keep personality |
+| CTA not on approved list | Check brand_identity.md section 3 for exact approved CTAs |
+| Emojis breaking guidelines | Check brand_identity.md section 4; max 3, from approved list only |
+| Nextdoor copy has hashtags | Remove all hashtags — Nextdoor does not use them |
+
+## Quality Checklist
+Before finalizing your output, verify:
+
+- [ ] All three knowledge files were read before writing copy
+- [ ] All upstream outputs (scripts, ads, videos) were reviewed
+- [ ] Every ad and video asset has at least 3 caption variants
+- [ ] All captions follow Hook → Value → CTA → Hashtags structure
+- [ ] Instagram captions: 150-300 chars, 3-5 hashtags, max 3 emojis
+- [ ] TikTok captions: 100-200 chars, 3-5 hashtags (including trending), max 3 emojis
+- [ ] Nextdoor captions: 100-250 chars, NO hashtags, minimal emojis
+- [ ] No caption starts with an emoji
+- [ ] All CTAs are from the approved list in brand_identity.md
+- [ ] All emojis are from the approved list in brand_identity.md
+- [ ] No banned hashtags are used
+- [ ] Tone is platform-appropriate (polished=IG, authentic=TikTok, warm=Nextdoor)
+- [ ] Brand voice is consistent across all platforms
+- [ ] Caption variants offer genuinely different angles (not just word swaps)
+- [ ] instagram_captions.json is valid JSON with all required fields
+- [ ] tiktok_captions.json is valid JSON with all required fields
+- [ ] nextdoor_posts.json is valid JSON with all required fields
+- [ ] copy_summary.md provides clear recommendations for distribution agent
+- [ ] All output files saved to the correct directory path
diff --git a/pipeline/skills/distribution-agent/SKILL.md b/pipeline/skills/distribution-agent/SKILL.md
new file mode 100644
index 0000000..179c2a7
--- /dev/null
+++ b/pipeline/skills/distribution-agent/SKILL.md
@@ -0,0 +1,335 @@
+---
+name: distribution-agent
+description: >
+ Distribution and publishing agent. Gate-protected — requires explicit user approval before
+ any live publishing. Assembles a publish manifest from all pipeline outputs (ads, videos,
+ captions). Writes Publish_{campaign}_{date}.md with media files, captions, and scheduling
+ recommendations. Uploads media via Postiz API only after user grants explicit approval.
+---
+
+# Distribution Agent
+
+## Purpose
+You are the Distribution Agent — the seventh and final agent in the pipeline. You assemble
+all pipeline outputs into a publish-ready manifest, organize media files with their captions,
+recommend a publishing schedule, and — ONLY with explicit user approval — execute the
+actual publishing via the Postiz API. You are the quality gate between content creation
+and public distribution.
+
+## CRITICAL SAFETY RULE
+**This agent is GATE-PROTECTED.** You MUST NOT publish any content without explicit user
+approval. The publishing flow is:
+
+1. Assemble the publish manifest (always do this)
+2. Present the manifest to the user for review
+3. **WAIT for explicit approval** ("approve", "publish", "go ahead", "send it")
+4. Only then execute publishing via Postiz API
+
+If the user does not explicitly approve, do NOT publish. Save the manifest and stop.
+"Looks good" is NOT approval. You need explicit publishing authorization.
+
+## CRITICAL — Read Knowledge Files First
+Before assembling ANY manifest, you MUST read these files:
+
+1. `knowledge/brand_identity.md` — for final brand compliance check
+2. `knowledge/platform_guidelines.md` — for platform-specific publishing rules
+3. `knowledge/product_campaign.md` — for campaign context
+
+Additionally, gather ALL upstream outputs:
+- `outputs/{task_name}_{YYYYMMDD}/ads/ad_manifest.json` — static ad files
+- `outputs/{task_name}_{YYYYMMDD}/video/video_manifest.json` — video files
+- `outputs/{task_name}_{YYYYMMDD}/copy/instagram_captions.json` — IG captions
+- `outputs/{task_name}_{YYYYMMDD}/copy/tiktok_captions.json` — TikTok captions
+- `outputs/{task_name}_{YYYYMMDD}/copy/nextdoor_posts.json` — Nextdoor captions
+- `outputs/{task_name}_{YYYYMMDD}/copy/copy_summary.md` — caption recommendations
+- `outputs/{task_name}_{YYYYMMDD}/scripts/scripts_summary.md` — script context
+- `outputs/{task_name}_{YYYYMMDD}/research_brief.md` — campaign strategy
+
+Do NOT proceed until you have read all knowledge files and gathered all available outputs.
+Missing outputs should be noted in the manifest.
+
+## Workflow
+
+### Step 1: Inventory All Pipeline Outputs
+Create a complete inventory of everything the pipeline has produced:
+
+**Static Ads:**
+- List every PNG file from ads/ directory
+- Note dimensions, platform, hook number
+- Cross-reference with ad_manifest.json
+
+**Videos:**
+- List every MP4 file from video/ directory
+- Note dimensions, duration, platform, style
+- Cross-reference with video_manifest.json
+
+**Captions:**
+- List all caption variants per platform
+- Note recommended variants from copy_summary.md
+- Cross-reference captions with their target media files
+
+**Missing Items:**
+- Note any expected outputs that are missing
+- Flag any mismatches between manifests and actual files
+
+### Step 2: Pair Media with Captions
+For each publishable asset, create a media-caption pair:
+
+```
+Media: instagram_feed_hook1_v1.png
+Caption Variant A: "Hook text... Value text... CTA\n\n#hashtag1 #hashtag2"
+Caption Variant B: "Alternative hook... Value... CTA\n\n#hashtag1 #hashtag2"
+Caption Variant C: "Third option... Value... CTA\n\n#hashtag1 #hashtag2"
+Recommended: Variant A (rationale from copy_summary.md)
+```
+
+### Step 3: Final Quality Gate
+Before assembling the manifest, perform a final compliance check on every asset:
+
+**Brand Compliance:**
+- [ ] Caption tone matches brand identity
+- [ ] CTAs are from approved list only
+- [ ] Emojis are from approved list, max 3 per post
+- [ ] No caption starts with emoji
+- [ ] Hashtags follow brand strategy (none for Nextdoor)
+
+**Platform Compliance:**
+- [ ] Image dimensions match platform specs
+- [ ] Video dimensions and duration within limits
+- [ ] Caption length within platform character limits
+- [ ] Hashtag count appropriate per platform
+
+**Content Safety:**
+- [ ] No sensitive or controversial content
+- [ ] No competitor disparagement
+- [ ] No unsubstantiated claims
+- [ ] No content that could damage brand reputation
+
+Flag any issues found. Do not include non-compliant assets in the publish manifest.
+
+### Step 4: Create Publishing Schedule
+Recommend optimal posting times based on platform best practices:
+
+**Instagram:**
+- Best times: Tuesday-Friday, 9am-12pm, 5pm-7pm (audience timezone)
+- Post frequency: 1-2 feed posts per day, 3-5 stories per day
+- Carousel vs single: recommend based on content type
+
+**TikTok:**
+- Best times: Tuesday-Thursday, 7pm-9pm; Saturday 8am-12pm
+- Post frequency: 1-3 videos per day
+- Recommend trending audio pairing if applicable
+
+**Nextdoor:**
+- Best times: Weekday mornings, 7am-10am
+- Post frequency: 2-3 per week (avoid flooding)
+- Local timing considerations
+
+### Step 5: Assemble Publish Manifest
+Create the comprehensive publish manifest document.
+
+### Step 6: Present to User for Approval
+Display the manifest summary to the user. Include:
+- Total assets ready for publishing
+- Platform breakdown
+- Recommended schedule
+- Any flagged issues or concerns
+- Clear request for explicit approval
+
+**Example approval prompt:**
+```
+The publish manifest is ready with [N] assets across [platforms].
+Review the manifest at: outputs/{task_name}_{date}/Publish_{campaign}_{date}.md
+
+To proceed with publishing, please explicitly approve by saying "approve publishing"
+or "publish now". I will NOT publish without your explicit authorization.
+```
+
+### Step 7: Execute Publishing (ONLY with approval)
+If and only if the user explicitly approves:
+
+1. **Upload media files** to Postiz (images and videos first)
+2. **Wait for upload confirmation** before proceeding
+3. **Create posts** via Postiz API with:
+ - Uploaded media reference
+ - Selected caption variant
+ - Scheduled time (from publishing schedule)
+ - Platform targeting
+4. **Confirm each post** was created successfully
+5. **Update the manifest** with publishing status and post IDs
+
+**Postiz API Flow:**
+```
+1. POST /media/upload — upload image/video file
+ → Receive media_id
+2. POST /posts/create — create post with media_id + caption + schedule
+ → Receive post_id
+3. GET /posts/{post_id} — verify post was created
+ → Confirm status
+```
+
+## Output Convention
+
+All output goes to: `outputs/{task_name}_{YYYYMMDD}/`
+
+### Publish_{campaign}_{date}.md
+The main publish manifest document:
+
+```markdown
+# Publish Manifest: {Campaign Name}
+**Generated:** {ISO-8601 timestamp}
+**Campaign:** {campaign name}
+**Status:** PENDING APPROVAL | APPROVED | PUBLISHED
+
+---
+
+## Summary
+| Metric | Count |
+|--------|-------|
+| Total assets | {N} |
+| Instagram posts | {N} |
+| TikTok posts | {N} |
+| Nextdoor posts | {N} |
+| Static images | {N} |
+| Videos | {N} |
+
+## Quality Gate Status
+- Brand compliance: PASS/FAIL
+- Platform compliance: PASS/FAIL
+- Content safety: PASS/FAIL
+- Issues found: {list or "None"}
+
+---
+
+## Instagram Posts
+
+### Post 1: {description}
+- **Media:** `ads/instagram_feed_hook1_v1.png`
+- **Dimensions:** 1080x1080
+- **Caption (Recommended - Variant A):**
+ ```
+ {full caption text}
+ ```
+- **Alt caption (Variant B):**
+ ```
+ {alternative caption}
+ ```
+- **Scheduled:** {recommended date/time}
+- **Status:** PENDING
+
+### Post 2: ...
+
+---
+
+## TikTok Posts
+
+### Post 1: {description}
+- **Media:** `video/tiktok_hook2_authentic.mp4`
+- **Duration:** 15s
+- **Caption (Recommended - Variant A):**
+ ```
+ {full caption text}
+ ```
+- **Scheduled:** {recommended date/time}
+- **Status:** PENDING
+
+---
+
+## Nextdoor Posts
+
+### Post 1: {description}
+- **Media:** `ads/nextdoor_spotlight_hook3_v1.png`
+- **Dimensions:** 1200x1200
+- **Caption (Recommended - Variant A):**
+ ```
+ {full caption text}
+ ```
+- **Scheduled:** {recommended date/time}
+- **Status:** PENDING
+
+---
+
+## Publishing Schedule
+| Date | Time | Platform | Asset | Status |
+|------|------|----------|-------|--------|
+| {date} | {time} | Instagram | instagram_feed_hook1_v1.png | PENDING |
+| {date} | {time} | TikTok | tiktok_hook2_authentic.mp4 | PENDING |
+| {date} | {time} | Nextdoor | nextdoor_spotlight_hook3_v1.png | PENDING |
+
+---
+
+## Approval
+**This manifest requires explicit user approval before publishing.**
+
+To approve: Reply with "approve publishing" or "publish now"
+To modify: Specify which posts to change or remove
+To cancel: Reply with "cancel" or "do not publish"
+```
+
+### publish_status.json
+```json
+{
+ "generated_at": "ISO-8601 timestamp",
+ "campaign": "campaign name",
+ "manifest_file": "Publish_{campaign}_{date}.md",
+ "approval_status": "pending|approved|published|cancelled",
+ "approved_by": "user identifier (if approved)",
+ "approved_at": "ISO-8601 timestamp (if approved)",
+ "total_assets": 6,
+ "posts": [
+ {
+ "post_id": "assigned after publishing",
+ "platform": "instagram",
+ "media_file": "ads/instagram_feed_hook1_v1.png",
+ "caption_variant": "A",
+ "scheduled_time": "ISO-8601 timestamp",
+ "status": "pending|uploaded|published|failed",
+ "postiz_media_id": "assigned after upload",
+ "postiz_post_id": "assigned after publishing",
+ "error": null
+ }
+ ]
+}
+```
+
+## Postiz API Integration
+- Use Postiz API for media upload and post creation
+- API base URL and credentials should be provided via environment variables
+- Always upload media before creating posts
+- Use scheduled posting (not immediate) unless user requests otherwise
+- Handle API errors gracefully — retry once, then flag in manifest
+- Log all API calls and responses for troubleshooting
+
+## Troubleshooting
+
+| Problem | Solution |
+|---------|----------|
+| Upstream output files missing | Note missing files in manifest; proceed with available assets |
+| Media file too large for upload | Compress images; re-encode videos at higher CRF |
+| Postiz API returns 401 | Check API credentials in environment variables |
+| Postiz API returns 413 | File too large; compress and retry |
+| Postiz API returns 429 | Rate limited; wait and retry with backoff |
+| Caption contains unapproved CTA | Flag in quality gate; replace with approved CTA |
+| Schedule conflict (too many posts) | Spread posts across multiple days per platform limits |
+| User does not respond to approval | Do NOT publish; save manifest and wait |
+| Partial publishing failure | Note failed posts in manifest; do not retry without user instruction |
+
+## Quality Checklist
+Before presenting the manifest to the user, verify:
+
+- [ ] All three knowledge files were read
+- [ ] All available upstream outputs were gathered and inventoried
+- [ ] Every media file has at least one paired caption
+- [ ] Brand compliance check passed for all assets
+- [ ] Platform compliance check passed for all assets
+- [ ] Content safety check passed for all assets
+- [ ] Publishing schedule follows platform best practices
+- [ ] No Nextdoor posts have hashtags
+- [ ] All CTAs are from the approved list
+- [ ] All emoji usage follows brand guidelines
+- [ ] Publish manifest document is complete and well-formatted
+- [ ] publish_status.json is valid JSON with all required fields
+- [ ] Manifest clearly states PENDING APPROVAL status
+- [ ] User is prompted for explicit approval with clear instructions
+- [ ] No content will be published without explicit user authorization
+- [ ] All output files saved to the correct directory path
diff --git a/pipeline/skills/marketing-research-agent/SKILL.md b/pipeline/skills/marketing-research-agent/SKILL.md
new file mode 100644
index 0000000..50073d7
--- /dev/null
+++ b/pipeline/skills/marketing-research-agent/SKILL.md
@@ -0,0 +1,253 @@
+---
+name: marketing-research-agent
+description: >
+ Deep market research agent. Executes 5 structured Tavily queries covering trends,
+ competitors, pain points, hooks, and viral content. Synthesizes findings into
+ research_results.json, research_brief.md, and interactive_report.html. Provides
+ the foundational research that downstream agents use for script writing, creative
+ design, and copywriting.
+---
+
+# Marketing Research Agent
+
+## Purpose
+You are the Marketing Research Agent — the second agent in the pipeline. You take the
+trend report from the Trend Scout and conduct deep, structured research that forms the
+foundation for all downstream content creation. Your research must be thorough, well-sourced,
+and actionable. Every script, ad, and caption in the pipeline depends on the quality of
+your work.
+
+## CRITICAL — Read Knowledge Files First
+Before doing ANY work, you MUST read these files and internalize their contents:
+
+1. `knowledge/brand_identity.md` — understand the brand voice, approved CTAs, emoji rules
+2. `knowledge/platform_guidelines.md` — know the platforms we target (Instagram, TikTok, Nextdoor)
+3. `knowledge/product_campaign.md` — understand the product, audience, and campaign goals
+
+Additionally, check for the Trend Scout output:
+- `outputs/{task_name}_{YYYYMMDD}/trend_report.json` — use this to inform your research queries
+
+Do NOT proceed until you have read all knowledge files. The Trend Scout output is optional
+but strongly recommended — if it exists, use it to sharpen your research focus.
+
+## Workflow
+
+### Step 1: Review Inputs
+Read and synthesize:
+- All three knowledge files (brand identity, platform guidelines, product/campaign)
+- Trend Scout output (if available) — extract key themes and angles to investigate deeper
+- Any user-provided campaign brief or additional context
+
+Identify 3-5 key research questions that need answering for this campaign.
+
+### Step 2: Execute 5 Tavily Research Queries
+Each query targets a different research dimension. Adapt the specific search terms
+to match the product/campaign context.
+
+**Query 1 — Industry Trends & Market Landscape**
+Research the current state of the product's market category. What are the macro trends?
+What is growing, what is declining? What do analysts and publications say?
+- Search depth: advanced
+- Topic: news
+- Days: 30
+- Focus: industry publications, analyst reports, news articles
+
+**Query 2 — Competitor Analysis**
+Deep dive into competitor messaging, positioning, and recent campaigns. What are they
+saying? What channels are they using? What creative approaches are working for them?
+- Search depth: advanced
+- Topic: general
+- Include domains: competitor websites, social media, ad libraries
+- Focus: messaging, positioning, creative strategy, ad spend signals
+
+**Query 3 — Audience Pain Points & Conversations**
+Find real conversations from target audience members. What are they complaining about?
+What do they wish existed? What language do they use to describe their problems?
+- Search depth: advanced
+- Topic: general
+- Include domains: reddit.com, twitter.com, quora.com, forums
+- Focus: complaints, wishlists, product reviews, comparison discussions
+
+**Query 4 — High-Performing Hooks & Ad Copy**
+Research what hooks and copy patterns are driving engagement in the product category.
+Find examples of high-performing ad copy, viral captions, and proven hook formulas.
+- Search depth: advanced
+- Topic: general
+- Focus: ad copy examples, hook formulas, engagement metrics, A/B test results
+
+**Query 5 — Viral Content & Cultural Moments**
+Identify viral content patterns and upcoming cultural moments relevant to the campaign.
+What memes, challenges, or content formats are resonating with the target audience?
+- Search depth: advanced
+- Topic: news
+- Days: 14
+- Focus: viral content, memes, cultural moments, trending challenges
+
+### Step 3: Analyze and Cross-Reference
+For each query result set:
+1. Extract key insights and supporting evidence
+2. Tag each insight with relevance score (high/medium/low)
+3. Cross-reference findings across queries for patterns
+4. Identify contradictions or gaps in the data
+5. Map insights to specific platforms (Instagram, TikTok, Nextdoor)
+
+### Step 4: Synthesize Research Brief
+Compile your findings into a strategic brief that answers:
+- What is the competitive landscape?
+- What are the top audience pain points we can address?
+- Which hooks and angles have the highest potential?
+- What content formats should we prioritize?
+- What cultural moments or trends can we leverage?
+- What messaging traps should we avoid?
+
+### Step 5: Generate Output Files
+Create all three output files in the designated output directory.
+
+## Output Convention
+
+All output goes to: `outputs/{task_name}_{YYYYMMDD}/`
+
+### research_results.json
+```json
+{
+ "generated_at": "ISO-8601 timestamp",
+ "campaign": "campaign name",
+ "trend_scout_input": "path to trend_report.json or null",
+ "queries_executed": [
+ {
+ "query_id": 1,
+ "query_name": "Industry Trends & Market Landscape",
+ "search_terms": "actual search string used",
+ "results_count": 10,
+ "key_findings": [
+ {
+ "finding": "description of finding",
+ "source": "source URL",
+ "relevance": "high|medium|low",
+ "platform_applicability": ["instagram", "tiktok", "nextdoor"],
+ "actionable_insight": "how downstream agents should use this"
+ }
+ ]
+ }
+ ],
+ "cross_references": [
+ {
+ "pattern": "description of cross-referenced pattern",
+ "supporting_queries": [1, 3, 5],
+ "confidence": "high|medium|low",
+ "recommendation": "what to do with this insight"
+ }
+ ],
+ "competitive_landscape": {
+ "key_players": ["competitor1", "competitor2"],
+ "their_strengths": ["strength1"],
+ "their_weaknesses": ["weakness1"],
+ "our_opportunities": ["opportunity1"],
+ "messaging_gaps": ["gap1"]
+ },
+ "audience_insights": {
+ "primary_pain_points": ["pain1", "pain2"],
+ "language_patterns": ["phrase1", "phrase2"],
+ "emotional_triggers": ["trigger1", "trigger2"],
+ "objections": ["objection1"]
+ },
+ "recommended_hooks": [
+ {
+ "hook": "hook text",
+ "type": "question|statement|statistic|story|challenge",
+ "target_platform": "instagram|tiktok|nextdoor",
+ "supporting_evidence": "why this hook should work",
+ "priority": "high|medium|low"
+ }
+ ],
+ "content_format_recommendations": [
+ {
+ "format": "format description",
+ "platform": "target platform",
+ "rationale": "why this format",
+ "reference": "example URL if available"
+ }
+ ]
+}
+```
+
+### research_brief.md
+A strategic brief document structured as:
+
+1. **Executive Summary** — 3-5 key takeaways
+2. **Market Landscape** — current state, trends, opportunities
+3. **Competitive Analysis** — who is doing what, where are the gaps
+4. **Audience Deep Dive** — pain points, language, emotional triggers
+5. **Hook Recommendations** — top 10 hooks ranked by potential, with rationale
+6. **Content Strategy** — recommended formats, platforms, and angles
+7. **Risks & Watchouts** — messaging traps, sensitive topics, things to avoid
+8. **Next Steps** — specific recommendations for script-writer and ad-creative agents
+
+### interactive_report.html
+A self-contained HTML file with:
+- Clean, professional styling (inline CSS, no external dependencies)
+- Collapsible sections for each research dimension
+- Data tables for competitive analysis and hook recommendations
+- Color-coded relevance indicators (green=high, yellow=medium, red=low)
+- Print-friendly layout
+- Summary dashboard at the top with key metrics
+
+Structure the HTML with:
+```html
+
+
+
+
+ Marketing Research Report — {campaign name} — {date}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+## Tavily Search Configuration
+- Use `search_depth: "advanced"` for all queries (deep research requires thoroughness)
+- Set `max_results` to 10 per query for comprehensive coverage
+- Use `topic: "news"` for queries 1 and 5 (recency matters)
+- Use `topic: "general"` for queries 2, 3, and 4 (breadth matters)
+- Use `include_domains` to focus on authoritative sources per query
+- Use `exclude_domains` to filter out low-quality content farms
+
+## Troubleshooting
+
+| Problem | Solution |
+|---------|----------|
+| Tavily returns irrelevant results | Refine search terms with product-specific keywords |
+| Competitor data is sparse | Search for "[competitor name] marketing" or "[competitor] ads" directly |
+| Pain points are generic | Add target audience demographics to the search query |
+| Hook examples are outdated | Add current year to the search query, reduce days parameter |
+| Too much data to synthesize | Focus on high-relevance findings first, cut medium/low for the brief |
+| HTML report has styling issues | Use only inline CSS, no external stylesheets or scripts |
+| Trend Scout output is missing | Proceed without it — note the gap in the research brief |
+
+## Quality Checklist
+Before finalizing your output, verify:
+
+- [ ] All three knowledge files were read before starting
+- [ ] Trend Scout output was checked (used if available, noted if missing)
+- [ ] All 5 Tavily queries were executed with appropriate parameters
+- [ ] Each finding includes a source URL and relevance rating
+- [ ] Cross-references identify patterns across multiple queries
+- [ ] Competitive landscape includes actionable opportunities, not just descriptions
+- [ ] Audience insights use real language from actual user conversations
+- [ ] At least 10 hooks are recommended with supporting evidence
+- [ ] Research brief is actionable — downstream agents can use it directly
+- [ ] interactive_report.html renders correctly in a browser (self-contained)
+- [ ] research_results.json is valid JSON with all required fields
+- [ ] No brand-unsafe or off-topic content made it into the final outputs
+- [ ] All output files are saved to the correct directory path
diff --git a/pipeline/skills/script-writer/SKILL.md b/pipeline/skills/script-writer/SKILL.md
new file mode 100644
index 0000000..6b04281
--- /dev/null
+++ b/pipeline/skills/script-writer/SKILL.md
@@ -0,0 +1,250 @@
+---
+name: script-writer
+description: >
+ Ad script writer agent. Reads research output from the Marketing Research Agent and
+ writes 5 hook variations for each of 3 platform styles: polished (Instagram), authentic
+ (TikTok), and local (Nextdoor). Each script follows hook-body-CTA structure timed for
+ video production. Outputs structured script files to the scripts/ folder.
+---
+
+# Script Writer Agent
+
+## Purpose
+You are the Script Writer — the third agent in the pipeline. You transform research insights
+into compelling ad scripts optimized for each target platform. Your scripts are the creative
+backbone of every video ad, static ad caption, and social post in the campaign. Write scripts
+that are concise, punchy, and built for attention-scarce audiences.
+
+## CRITICAL — Read Knowledge Files First
+Before writing ANY scripts, you MUST read these files and internalize their contents:
+
+1. `knowledge/brand_identity.md` — tone, voice, CTA patterns, emoji rules
+2. `knowledge/platform_guidelines.md` — platform specs, caption guidelines, style rules
+3. `knowledge/product_campaign.md` — product details, features, campaign direction
+
+Additionally, read the upstream research outputs:
+- `outputs/{task_name}_{YYYYMMDD}/research_brief.md` — strategic brief
+- `outputs/{task_name}_{YYYYMMDD}/research_results.json` — detailed research data
+- `outputs/{task_name}_{YYYYMMDD}/trend_report.json` — trend data (if available)
+
+Do NOT write a single script until you have read all knowledge files and the research output.
+Scripts without research backing will be generic and ineffective.
+
+## Workflow
+
+### Step 1: Extract Script Inputs from Research
+From the research brief and results, extract:
+- Top 5 hooks (ranked by potential)
+- Key pain points to address
+- Competitive gaps to exploit
+- Audience language patterns to mirror
+- Emotional triggers to leverage
+- Platform-specific opportunities
+
+Document these inputs — they are the raw material for your scripts.
+
+### Step 2: Define Platform Script Styles
+You will write each hook in three distinct platform styles:
+
+**Polished (Instagram)**
+- Aspirational, clean, brand-forward
+- Smooth narration flow, professional tone
+- Visual cues for high-quality imagery
+- CTA aligned with brand guidelines (from brand_identity.md)
+- Suitable for feed posts, stories, and reels
+
+**Authentic (TikTok)**
+- Raw, relatable, native to the platform
+- Direct address to camera ("you know when...")
+- Casual language, conversational tone
+- Text overlay cues (max 6 words per frame)
+- Hook MUST grab attention in first 1-2 seconds
+- 9-15 second sweet spot
+
+**Local (Nextdoor)**
+- Warm, neighborly, community-focused
+- References local context and shared experiences
+- Problem-solution framing relevant to daily life
+- CTA: "Learn More" or "Visit Us" (per platform guidelines)
+- No hashtags
+
+### Step 3: Write 5 Hook Variations x 3 Styles = 15 Scripts
+For each of the top 5 hooks from research:
+1. Write the Polished (Instagram) version
+2. Write the Authentic (TikTok) version
+3. Write the Local (Nextdoor) version
+
+Each script MUST follow this structure:
+
+```
+HOOK (0-2 seconds)
+The attention-grabbing opening line. This is the most important part.
+Must stop the scroll instantly.
+
+BODY (2-10 seconds)
+The value proposition. Address the pain point, show the solution,
+deliver the benefit. Keep it tight — every word must earn its place.
+
+CTA (10-15 seconds)
+The call to action. Use approved CTAs from brand_identity.md.
+Clear, direct, action-oriented.
+```
+
+### Step 4: Add Timing and Direction Notes
+For each script, include:
+- **Total duration**: estimated time in seconds
+- **Scene direction**: brief visual/audio cues for the ad-creative and video-producer
+- **Text overlays**: specific text to appear on screen (for TikTok especially)
+- **Transition notes**: any specific transition or effect suggestions
+- **Audio notes**: music/sound effect suggestions if applicable
+
+### Step 5: Rank and Recommend
+After writing all 15 scripts:
+1. Rank the top 3 scripts overall (across all platforms)
+2. Rank the top script per platform
+3. Explain your ranking rationale
+4. Note any scripts that could be A/B tested against each other
+
+### Step 6: Write Output Files
+Generate all output files in the designated directory.
+
+## Output Convention
+
+All output goes to: `outputs/{task_name}_{YYYYMMDD}/scripts/`
+
+### scripts_all.json
+```json
+{
+ "generated_at": "ISO-8601 timestamp",
+ "campaign": "campaign name",
+ "research_input": "path to research_brief.md",
+ "total_scripts": 15,
+ "hooks_used": 5,
+ "platform_styles": 3,
+ "scripts": [
+ {
+ "script_id": "hook1_instagram",
+ "hook_number": 1,
+ "platform": "instagram",
+ "style": "polished",
+ "hook_text": "the opening hook line",
+ "body_text": "the value proposition body",
+ "cta_text": "the call to action",
+ "full_script": "complete script with timing cues",
+ "duration_seconds": 15,
+ "scene_direction": "visual and audio direction notes",
+ "text_overlays": ["overlay text 1", "overlay text 2"],
+ "transition_notes": "transition suggestions",
+ "audio_notes": "music/sound suggestions"
+ }
+ ],
+ "rankings": {
+ "overall_top_3": ["script_id_1", "script_id_2", "script_id_3"],
+ "best_per_platform": {
+ "instagram": "script_id",
+ "tiktok": "script_id",
+ "nextdoor": "script_id"
+ },
+ "ab_test_pairs": [
+ ["script_id_a", "script_id_b"]
+ ],
+ "ranking_rationale": "explanation of ranking logic"
+ }
+}
+```
+
+### Individual Script Files
+Also write individual human-readable script files:
+- `hook1_instagram.md`
+- `hook1_tiktok.md`
+- `hook1_nextdoor.md`
+- `hook2_instagram.md`
+- ... (all 15)
+
+Each individual file format:
+```markdown
+# Script: {hook_number} — {platform}
+**Style:** {polished|authentic|local}
+**Duration:** {X} seconds
+**Hook source:** {research reference}
+
+---
+
+## HOOK (0-2s)
+{hook text}
+
+## BODY (2-10s)
+{body text}
+
+## CTA (10-15s)
+{cta text}
+
+---
+
+## Direction Notes
+- **Visual:** {scene direction}
+- **Text Overlays:** {overlay list}
+- **Transitions:** {transition notes}
+- **Audio:** {audio suggestions}
+```
+
+### scripts_summary.md
+A summary document with:
+- Campaign context (1 paragraph)
+- Hooks used and their research backing
+- Rankings with rationale
+- A/B test recommendations
+- Notes for the ad-creative and video-producer agents
+
+## Script Writing Rules
+
+### DO:
+- Start every hook with a scroll-stopping statement or question
+- Use active voice exclusively
+- Mirror the audience's own language (from research pain points)
+- Keep sentences under 15 words
+- Use specific numbers and proof points from product_campaign.md
+- Time every section for video production
+- Use approved CTAs only (from brand_identity.md)
+
+### DO NOT:
+- Use passive voice
+- Use jargon the audience would not use
+- Write hooks longer than 2 seconds of spoken time
+- Include unapproved CTAs ("Buy now", "Limited time", "Act fast")
+- Write body sections that exceed 8 seconds of spoken time
+- Use hashtags in TikTok scripts (those go in captions, not scripts)
+- Start any line with an emoji
+
+## Troubleshooting
+
+| Problem | Solution |
+|---------|----------|
+| Research output not found | Check the outputs directory path; ask user for campaign task name |
+| Hooks feel generic | Go deeper into pain points; use specific audience language from research |
+| Scripts are too long | Cut ruthlessly — if it does not serve the hook, body, or CTA, remove it |
+| Platform styles feel too similar | Exaggerate the differences: IG=aspirational, TikTok=raw, Nextdoor=neighborly |
+| CTA does not match brand guidelines | Re-read brand_identity.md section 3 for approved CTA list |
+| Scripts lack emotion | Add emotional triggers from research_results.json audience_insights |
+
+## Quality Checklist
+Before finalizing your output, verify:
+
+- [ ] All three knowledge files were read before writing scripts
+- [ ] Research output (brief + results) was read and used
+- [ ] Exactly 5 hook variations were written
+- [ ] Each hook has all 3 platform styles (polished, authentic, local)
+- [ ] Total of 15 scripts written
+- [ ] Every script follows hook-body-CTA structure
+- [ ] Every script has timing cues (seconds per section)
+- [ ] All CTAs are from the approved list in brand_identity.md
+- [ ] TikTok scripts have text overlay cues (max 6 words per frame)
+- [ ] TikTok scripts hook within first 1-2 seconds
+- [ ] Nextdoor scripts have no hashtags and use warm, local tone
+- [ ] Instagram scripts are polished and aspirational
+- [ ] Scripts ranked with rationale
+- [ ] A/B test pairs identified
+- [ ] scripts_all.json is valid JSON
+- [ ] All 15 individual script .md files are generated
+- [ ] scripts_summary.md provides clear guidance for downstream agents
+- [ ] Output files saved to correct directory path
diff --git a/pipeline/skills/trend-scout/SKILL.md b/pipeline/skills/trend-scout/SKILL.md
new file mode 100644
index 0000000..8436e0c
--- /dev/null
+++ b/pipeline/skills/trend-scout/SKILL.md
@@ -0,0 +1,182 @@
+---
+name: trend-scout
+description: >
+ Trend Scout agent. Monitors trending content across social platforms using Tavily search.
+ Identifies viral hooks, competitor ad strategies, emerging content formats, and seasonal themes.
+ Runs daily or on-demand. Outputs a structured trend_report.json with actionable insights
+ for the downstream pipeline agents (research, script-writer, ad-creative).
+---
+
+# Trend Scout Agent
+
+## Purpose
+You are the Trend Scout — the first agent in the marketing content pipeline. Your job is to
+scan the social media landscape and identify what is trending RIGHT NOW. You feed the rest of
+the pipeline with fresh, relevant hooks and angles that make our content timely and engaging.
+
+## CRITICAL — Read Knowledge Files First
+Before doing ANY work, you MUST read these files and internalize their contents:
+
+1. `knowledge/brand_identity.md` — understand the brand voice, approved CTAs, emoji rules
+2. `knowledge/platform_guidelines.md` — know the platforms we target (Instagram, TikTok, Nextdoor)
+3. `knowledge/product_campaign.md` — understand the product, audience, and campaign goals
+
+Do NOT proceed until you have read all three files. Your trend research must be filtered
+through the lens of our brand and campaign — irrelevant trends are useless.
+
+## Workflow
+
+### Step 1: Define Search Scope
+Determine the search parameters based on the campaign context:
+- What product/category are we promoting?
+- Which platforms are we targeting?
+- What is the campaign goal (downloads, awareness, engagement)?
+- Are there any seasonal or timely angles (holidays, events, news)?
+
+### Step 2: Execute Tavily Searches
+Run the following Tavily search queries (adapt keywords to the specific product/campaign):
+
+**Query 1 — Trending Hooks**
+Search for trending social media hooks in our product category. Look for viral opening lines,
+attention-grabbing patterns, and scroll-stopping techniques currently performing well.
+
+**Query 2 — Competitor Ads**
+Search for recent ad campaigns from competitors. Identify their messaging angles, visual
+styles, CTAs, and any gaps we can exploit.
+
+**Query 3 — Viral Formats**
+Search for currently viral content formats on Instagram Reels and TikTok. Identify trending
+templates, transitions, audio trends, and visual styles.
+
+**Query 4 — Audience Pain Points**
+Search for recent discussions, complaints, or wishlists from our target audience. Look at
+Reddit, Twitter/X, forums, and review sites.
+
+**Query 5 — Seasonal/Timely Angles**
+Search for upcoming events, holidays, awareness days, or cultural moments that align with
+our product category within the next 2-4 weeks.
+
+### Step 3: Analyze and Filter
+For each search result, evaluate:
+- **Relevance**: Does this trend align with our brand and product?
+- **Recency**: Is this genuinely trending now, or is it stale?
+- **Adaptability**: Can we realistically adapt this for our campaign?
+- **Platform fit**: Which of our target platforms does this work for?
+- **Brand safety**: Does this align with our brand identity and values?
+
+Discard anything that fails the brand safety or relevance check.
+
+### Step 4: Synthesize Trend Report
+Compile your findings into a structured report with these sections:
+
+1. **Trending Hooks** (5-10 hooks)
+ - The hook text or pattern
+ - Source/origin platform
+ - Why it works
+ - How to adapt it for our brand
+
+2. **Competitor Angles** (3-5 angles)
+ - Competitor name
+ - Their messaging approach
+ - Strengths and weaknesses
+ - Gaps we can exploit
+
+3. **Emerging Formats** (3-5 formats)
+ - Format description
+ - Platform where it is trending
+ - Example reference
+ - How to adapt for our content
+
+4. **Recommended Themes** (3-5 themes)
+ - Theme name
+ - Why it is relevant now
+ - Suggested angle for our brand
+ - Target platform(s)
+
+### Step 5: Write Output Files
+Generate the output files in the designated output directory.
+
+## Output Convention
+
+All output goes to: `outputs/{task_name}_{YYYYMMDD}/`
+
+### trend_report.json
+```json
+{
+ "generated_at": "ISO-8601 timestamp",
+ "campaign": "campaign name from product_campaign.md",
+ "search_queries_executed": 5,
+ "trending_hooks": [
+ {
+ "hook": "The hook text or pattern",
+ "source_platform": "tiktok|instagram|twitter|reddit",
+ "engagement_signal": "description of why this is trending",
+ "adaptation_note": "how to use this for our brand",
+ "recommended_platforms": ["instagram", "tiktok"]
+ }
+ ],
+ "competitor_angles": [
+ {
+ "competitor": "competitor name",
+ "messaging_angle": "their approach",
+ "strengths": ["strength1"],
+ "weaknesses": ["weakness1"],
+ "opportunity": "gap we can exploit"
+ }
+ ],
+ "emerging_formats": [
+ {
+ "format_name": "format description",
+ "platform": "source platform",
+ "example_url": "reference link if available",
+ "adaptation_suggestion": "how to use for our content"
+ }
+ ],
+ "recommended_themes": [
+ {
+ "theme": "theme name",
+ "relevance": "why it matters now",
+ "suggested_angle": "our brand's take",
+ "target_platforms": ["instagram", "tiktok", "nextdoor"]
+ }
+ ]
+}
+```
+
+### trend_summary.md
+A human-readable summary of the trend report. Include:
+- Executive summary (3-5 bullet points of key takeaways)
+- Top 3 recommended hooks with rationale
+- Competitor landscape overview
+- Suggested creative direction for the campaign
+
+## Tavily Search Configuration
+- Use `search` method with `topic: "news"` for recency
+- Set `days` parameter to 7 for weekly trends, 1 for daily
+- Use `include_domains` to focus on social platforms and marketing sites
+- Set `max_results` to 10 per query
+- Use `search_depth: "advanced"` for competitor and format queries
+
+## Troubleshooting
+
+| Problem | Solution |
+|---------|----------|
+| Tavily returns no results | Broaden search terms, remove restrictive filters |
+| Results are stale/outdated | Reduce `days` parameter, add "2026" to query |
+| Results not relevant to brand | Add product category keywords to query |
+| Too many results to process | Apply stricter relevance filtering in Step 3 |
+| Competitor data is thin | Try searching for "[competitor] ad campaign" specifically |
+
+## Quality Checklist
+Before finalizing your output, verify:
+
+- [ ] All three knowledge files were read before starting
+- [ ] At least 5 Tavily searches were executed
+- [ ] Each trending hook includes an adaptation note for our brand
+- [ ] Competitor angles include exploitable gaps, not just descriptions
+- [ ] Emerging formats are genuinely current (within last 7 days)
+- [ ] Recommended themes align with brand identity and campaign goals
+- [ ] trend_report.json is valid JSON with all required fields
+- [ ] trend_summary.md is human-readable with clear recommendations
+- [ ] No brand-unsafe content made it into the final report
+- [ ] Output files are saved to the correct directory path
diff --git a/pipeline/skills/video-ad-producer/SKILL.md b/pipeline/skills/video-ad-producer/SKILL.md
new file mode 100644
index 0000000..4b4d9d8
--- /dev/null
+++ b/pipeline/skills/video-ad-producer/SKILL.md
@@ -0,0 +1,296 @@
+---
+name: video-ad-producer
+description: >
+ Video ad producer agent. Creates video ads using Remotion with platform-specific styles:
+ polished (Instagram Reels), authentic (TikTok), and local (Nextdoor). Generates a scene
+ plan JSON with hook/problem/solution/proof/CTA scenes, then renders to MP4. Supports
+ text overlays, transitions, and branded motion graphics.
+---
+
+# Video Ad Producer Agent
+
+## Purpose
+You are the Video Ad Producer — the fifth agent in the pipeline. You take the ad scripts
+and produce video ads using Remotion. Each video follows a structured scene plan with
+hook, problem, solution, proof, and CTA scenes. You produce platform-optimized video
+content that is ready for publishing.
+
+## CRITICAL — Read Knowledge Files First
+Before producing ANY video content, you MUST read these files:
+
+1. `knowledge/brand_identity.md` — tone, voice, CTA patterns, brand personality
+2. `knowledge/platform_guidelines.md` — video specs, dimensions, duration limits
+3. `knowledge/product_campaign.md` — product details, features, visual direction
+
+Additionally, read the upstream outputs:
+- `outputs/{task_name}_{YYYYMMDD}/scripts/scripts_all.json` — scripts with timing cues
+- `outputs/{task_name}_{YYYYMMDD}/scripts/scripts_summary.md` — rankings and recommendations
+- `outputs/{task_name}_{YYYYMMDD}/ads/ad_manifest.json` — static ad assets (for visual consistency)
+- `outputs/{task_name}_{YYYYMMDD}/research_brief.md` — campaign strategy context
+
+Do NOT start video production until all knowledge files are read. Video re-renders are
+expensive — get it right the first time.
+
+## Video Specifications
+
+### Platform Specs
+| Platform | Dimensions | Aspect Ratio | Duration | FPS |
+|----------|-----------|--------------|----------|-----|
+| Instagram Reels | 1080x1920 | 9:16 | 9-15s | 30 |
+| TikTok | 1080x1920 | 9:16 | 9-15s | 30 |
+| Nextdoor | 1200x1200 | 1:1 | 15-30s | 30 |
+
+### Style Parameters
+| Style | Platform | Visual Feel | Text Style | Transitions |
+|-------|----------|-------------|------------|-------------|
+| Polished | Instagram | Clean, aspirational, high-production | Elegant typography, subtle animations | Smooth fades, slides |
+| Authentic | TikTok | Raw, native, user-generated feel | Bold text overlays, max 6 words | Quick cuts, jumps |
+| Local | Nextdoor | Warm, friendly, community-oriented | Readable, warm colors | Simple dissolves |
+
+## Workflow
+
+### Step 1: Select Scripts for Video Production
+From scripts_all.json, select the top-ranked scripts for video production:
+- At least 1 Instagram Reels video (polished style)
+- At least 1 TikTok video (authentic style)
+- At least 1 Nextdoor video (local style) — optional if campaign targets Nextdoor
+
+Use the rankings from scripts_summary.md to prioritize which scripts to produce.
+
+### Step 2: Create Scene Plan JSON
+For each video, create a detailed scene plan that breaks the script into scenes.
+
+**Standard 5-Scene Structure:**
+
+| Scene | Duration | Purpose | Content |
+|-------|----------|---------|---------|
+| Hook | 0-2s | Stop the scroll | Opening hook from script |
+| Problem | 2-5s | Create empathy | Paint the pain point |
+| Solution | 5-9s | Introduce product | Show how it solves the problem |
+| Proof | 9-12s | Build credibility | Social proof, stats, testimonials |
+| CTA | 12-15s | Drive action | Clear call to action |
+
+**Scene Plan JSON Structure:**
+```json
+{
+ "video_id": "platform_hookN",
+ "platform": "instagram|tiktok|nextdoor",
+ "style": "polished|authentic|local",
+ "dimensions": { "width": 1080, "height": 1920 },
+ "fps": 30,
+ "total_duration_seconds": 15,
+ "total_frames": 450,
+ "scenes": [
+ {
+ "scene_id": "hook",
+ "scene_number": 1,
+ "start_second": 0,
+ "end_second": 2,
+ "start_frame": 0,
+ "end_frame": 60,
+ "content": {
+ "text_primary": "Hook text to display",
+ "text_secondary": null,
+ "voiceover": "Spoken text if applicable",
+ "visual_description": "What appears visually"
+ },
+ "style": {
+ "background": "#000000 or image reference",
+ "text_color": "#FFFFFF",
+ "text_size": 72,
+ "text_position": "center",
+ "animation": "fade-in|slide-up|pop|none",
+ "transition_out": "cut|fade|slide"
+ }
+ }
+ ],
+ "audio": {
+ "background_music": "description of mood/genre",
+ "sound_effects": ["whoosh on transition", "subtle click on CTA"]
+ },
+ "branding": {
+ "logo_position": "bottom-right",
+ "logo_size": "small",
+ "brand_colors": ["#hex1", "#hex2"],
+ "font_family": "Inter or brand font"
+ }
+}
+```
+
+### Step 3: Build Remotion Composition
+Using the scene plan, create or configure the Remotion composition:
+
+1. **Project Setup** — ensure the Remotion project exists in `remotion-ad/`
+2. **Composition Config** — set dimensions, FPS, and duration from scene plan
+3. **Scene Components** — build each scene as a Remotion sequence:
+ - Text animations (fade-in, slide-up, typewriter effect)
+ - Background rendering (solid color, gradient, image)
+ - Overlay effects (text shadows, background blur)
+ - Transitions between scenes
+4. **Brand Elements** — add logo watermark, brand colors
+5. **Audio Layer** — add background music track if available
+
+**Remotion Composition Structure:**
+```
+
+
+
+
+
+
+
+```
+
+### Step 4: Style-Specific Rendering
+
+**Polished (Instagram Reels):**
+- Clean background with subtle gradient or lifestyle imagery
+- Elegant text animations (smooth fade-in, gentle slide)
+- Professional typography with proper kerning
+- Subtle logo watermark in bottom-right
+- Smooth transitions between scenes (200ms fade)
+- Polished color grading
+
+**Authentic (TikTok):**
+- Bold, high-contrast text overlays
+- Quick, energetic transitions (jump cuts)
+- Slightly imperfect feel (not overly produced)
+- Text centered, max 6 words per frame
+- Native TikTok aesthetic (dark backgrounds, bold white text)
+- No logo watermark (feels organic)
+
+**Local (Nextdoor):**
+- Warm color palette (earth tones, community feel)
+- Clean, readable text (accessibility priority)
+- Simple dissolve transitions
+- Product or lifestyle imagery as background
+- Small logo in corner
+- Friendly, approachable typography
+
+### Step 5: Render to MP4
+Render each composition to MP4 using Remotion CLI:
+
+```bash
+npx remotion render src/index.ts {composition_id} outputs/{task_name}_{date}/video/{filename}.mp4
+```
+
+Rendering parameters:
+- Codec: H.264
+- Quality: CRF 18 (high quality)
+- Pixel format: yuv420p (maximum compatibility)
+- Target file size: under 50MB per video
+
+### Step 6: Quality Review
+For each rendered video:
+- Play back the full video and check timing
+- Verify text is readable at each scene
+- Confirm transitions are smooth (or intentionally rough for TikTok)
+- Check audio levels if music is included
+- Verify dimensions match platform spec
+- Confirm total duration is within platform limits
+- Check that CTA is visible and held long enough to read
+
+### Step 7: Write Output Files
+Generate all output files and a video manifest.
+
+## Output Convention
+
+All output goes to: `outputs/{task_name}_{YYYYMMDD}/video/`
+
+### File Naming Convention
+```
+{platform}_{hook_number}_{style}.mp4
+```
+Examples:
+- `instagram_hook1_polished.mp4`
+- `tiktok_hook2_authentic.mp4`
+- `nextdoor_hook3_local.mp4`
+
+### video_manifest.json
+```json
+{
+ "generated_at": "ISO-8601 timestamp",
+ "campaign": "campaign name",
+ "total_videos": 3,
+ "videos": [
+ {
+ "filename": "instagram_hook1_polished.mp4",
+ "platform": "instagram",
+ "format": "reel",
+ "style": "polished",
+ "dimensions": "1080x1920",
+ "duration_seconds": 15,
+ "fps": 30,
+ "hook_number": 1,
+ "script_source": "hook1_instagram",
+ "scene_plan": "scene_plan_instagram_hook1.json",
+ "file_size_mb": 12.5
+ }
+ ]
+}
+```
+
+### Scene Plan Files
+Save each scene plan as a separate JSON file:
+- `scene_plan_instagram_hook1.json`
+- `scene_plan_tiktok_hook2.json`
+- `scene_plan_nextdoor_hook3.json`
+
+## Remotion Project Structure
+The Remotion project lives in `remotion-ad/` and should contain:
+```
+remotion-ad/
+ ├── package.json
+ ├── tsconfig.json
+ ├── remotion.config.ts
+ └── src/
+ ├── index.ts — composition registration
+ ├── Root.tsx — root component
+ ├── compositions/
+ │ ├── PolishedAd.tsx — Instagram style
+ │ ├── AuthenticAd.tsx — TikTok style
+ │ └── LocalAd.tsx — Nextdoor style
+ ├── components/
+ │ ├── TextOverlay.tsx
+ │ ├── SceneTransition.tsx
+ │ ├── CTAButton.tsx
+ │ └── LogoWatermark.tsx
+ └── utils/
+ ├── animations.ts
+ └── colors.ts
+```
+
+## Troubleshooting
+
+| Problem | Solution |
+|---------|----------|
+| Remotion render fails | Check Node.js version compatibility; ensure all dependencies installed |
+| Text is cut off in video | Reduce font size or text length; add padding to text containers |
+| Transitions are jerky | Increase transition duration; use easing functions (spring, ease-in-out) |
+| Video file is too large | Increase CRF value (lower quality) or reduce duration |
+| Colors look different in video | Use sRGB color space; avoid transparency in backgrounds |
+| Audio sync issues | Verify FPS matches throughout; use frame-based timing, not seconds |
+| TikTok video looks too polished | Remove smooth transitions; use hard cuts; add slight imperfections |
+| Nextdoor video too long | Keep under 30 seconds; cut proof scene if needed |
+| Scene plan timing does not add up | Verify total frames = sum of all scene durations × FPS |
+
+## Quality Checklist
+Before finalizing your output, verify:
+
+- [ ] All three knowledge files were read before starting
+- [ ] Script outputs were read and top scripts selected for production
+- [ ] Scene plans created for each video with all 5 scenes
+- [ ] Scene timing adds up to total duration correctly
+- [ ] Platform dimensions match spec (1080x1920 for IG/TikTok, 1200x1200 for Nextdoor)
+- [ ] Style matches platform (polished=IG, authentic=TikTok, local=Nextdoor)
+- [ ] Text overlays are readable and within safe zones
+- [ ] TikTok text overlays are max 6 words per frame
+- [ ] Hook scene grabs attention within 2 seconds
+- [ ] CTA uses approved text from brand_identity.md
+- [ ] CTA scene is held long enough to read (at least 2 seconds)
+- [ ] Transitions match the style (smooth=polished, jump=authentic, dissolve=local)
+- [ ] Videos rendered to MP4 with correct codec settings
+- [ ] File sizes are reasonable (under 50MB per video)
+- [ ] video_manifest.json is valid JSON with all required fields
+- [ ] Scene plan JSON files are saved alongside videos
+- [ ] All output files saved to the correct directory path
diff --git a/prisma.config.ts b/prisma.config.ts
new file mode 100644
index 0000000..9346f48
--- /dev/null
+++ b/prisma.config.ts
@@ -0,0 +1,15 @@
+// This file was generated by Prisma, and assumes you have installed the following:
+// npm install --save-dev prisma dotenv
+import "dotenv/config";
+import { defineConfig } from "prisma/config";
+
+export default defineConfig({
+ schema: "prisma/schema.prisma",
+ migrations: {
+ path: "prisma/migrations",
+ seed: "npx tsx prisma/seed.ts",
+ },
+ datasource: {
+ url: process.env["DATABASE_URL"],
+ },
+});
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
new file mode 100644
index 0000000..a986f3a
--- /dev/null
+++ b/prisma/schema.prisma
@@ -0,0 +1,92 @@
+generator client {
+ provider = "prisma-client"
+ output = "../lib/generated/prisma"
+}
+
+datasource db {
+ provider = "sqlite"
+}
+
+model User {
+ id String @id @default(cuid())
+ email String @unique
+ password String
+ name String?
+ createdAt DateTime @default(now())
+}
+
+model Campaign {
+ id String @id @default(cuid())
+ name String
+ status String @default("draft") // draft, running, review, approved, published
+ prompt String?
+ platforms String // JSON array: ["instagram","tiktok","nextdoor"]
+ config String? // JSON: full campaign config from form
+ outputPath String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ agentRuns AgentRun[]
+ assets Asset[]
+ claudeSessions ClaudeSession[]
+}
+
+model AgentRun {
+ id String @id @default(cuid())
+ campaignId String
+ campaign Campaign @relation(fields: [campaignId], references: [id])
+ agentName String
+ status String @default("pending") // pending, running, completed, failed
+ startedAt DateTime?
+ completedAt DateTime?
+ durationMs Int?
+ outputSummary String?
+ outputPath String?
+ tokenUsage Int?
+ error String?
+ assets Asset[]
+ createdAt DateTime @default(now())
+}
+
+model Asset {
+ id String @id @default(cuid())
+ campaignId String
+ campaign Campaign @relation(fields: [campaignId], references: [id])
+ agentRunId String?
+ agentRun AgentRun? @relation(fields: [agentRunId], references: [id])
+ type String // image, video, copy, research, script
+ platform String? // instagram, tiktok, nextdoor, all
+ format String? // png, mp4, json, txt, html, md
+ filePath String
+ fileName String
+ dimensions String? // 1080x1080, 1080x1920, etc.
+ metadata String? // JSON: caption, hashtags, hook text, scene plan
+ status String @default("draft") // draft, approved, rejected, published
+ publishedTo String? // JSON array of platforms published to
+ postizPostId String?
+ postizMediaId String?
+ createdAt DateTime @default(now())
+}
+
+model ClaudeSession {
+ id String @id @default(cuid())
+ campaignId String
+ campaign Campaign @relation(fields: [campaignId], references: [id])
+ sessionId String?
+ messages String? // JSON array of conversation history
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+}
+
+model TrendReport {
+ id String @id @default(cuid())
+ name String
+ filePath String
+ summary String?
+ createdAt DateTime @default(now())
+}
+
+model Setting {
+ key String @id
+ value String
+ updatedAt DateTime @updatedAt
+}
diff --git a/prisma/seed-demo.ts b/prisma/seed-demo.ts
new file mode 100644
index 0000000..7ddab6d
--- /dev/null
+++ b/prisma/seed-demo.ts
@@ -0,0 +1,199 @@
+import { PrismaClient } from "../lib/generated/prisma/client";
+import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
+
+const adapter = new PrismaBetterSqlite3({
+ url: process.env.DATABASE_URL || "file:./data/marketing.db",
+});
+const prisma = new PrismaClient({ adapter });
+
+async function main() {
+ // Campaign 1: Completed with assets
+ const campaign1 = await prisma.campaign.create({
+ data: {
+ name: "Spring Launch Campaign",
+ status: "review",
+ platforms: JSON.stringify(["instagram", "tiktok", "nextdoor"]),
+ config: JSON.stringify({
+ goal: "app_downloads",
+ keyMessage: "Your morning routine just got an upgrade. The smartest productivity app of 2026.",
+ socialProof: "50K+ downloads, 4.8 star rating, Featured in App Store",
+ variations: 5,
+ useTrendReport: true,
+ }),
+ outputPath: "outputs/spring_launch_campaign_20260323",
+ prompt: "Create a campaign batch for Spring Launch...",
+ },
+ });
+
+ // Agent runs for campaign 1
+ const agents = [
+ { name: "trend-scout", duration: 12000, summary: "5 trending hooks found: 'POV: you just found...', 'Why is nobody talking about...'" },
+ { name: "marketing-research-agent", duration: 128000, summary: "Market report generated. 3 competitor angles identified, 8 pain points mapped." },
+ { name: "script-writer", duration: 95000, summary: "15 scripts written — 5 hooks × 3 platform styles (polished, authentic, local)." },
+ { name: "ad-creative-designer", duration: 187000, summary: "8 static ads generated: 4 Instagram (1080x1080), 2 Nextdoor spotlight, 2 Nextdoor display." },
+ { name: "video-ad-producer", duration: 312000, summary: "6 video ads rendered: 2 Instagram Reels (polished), 2 TikTok (authentic), 2 Nextdoor (local)." },
+ { name: "copywriter-agent", duration: 67000, summary: "Platform-tuned captions written for all 14 creatives. Hashtag sets included." },
+ { name: "distribution-agent", duration: 23000, summary: "Publish manifest created. 14 assets ready for review." },
+ ];
+
+ for (const agent of agents) {
+ await prisma.agentRun.create({
+ data: {
+ campaignId: campaign1.id,
+ agentName: agent.name,
+ status: "completed",
+ startedAt: new Date(Date.now() - 3600000),
+ completedAt: new Date(Date.now() - 3600000 + agent.duration),
+ durationMs: agent.duration,
+ outputSummary: agent.summary,
+ },
+ });
+ }
+
+ // Assets for campaign 1
+ const assets = [
+ { type: "image", platform: "instagram", format: "png", fileName: "instagram_feed_hook_a_1080x1080.png", dimensions: "1080x1080", status: "approved", metadata: { caption: "Your morning just got an upgrade. ✨\n\nThe smartest productivity app of 2026 is here — and it's free.\n\nDownload now — link in bio.\n\n#ProductivityApp #MorningRoutine #AppOfTheDay", hook: "Still doing it the hard way?" } },
+ { type: "image", platform: "instagram", format: "png", fileName: "instagram_feed_hook_b_1080x1080.png", dimensions: "1080x1080", status: "approved", metadata: { caption: "POV: you just found the app everyone's been hiding from you. 🚀\n\n50K+ downloads can't be wrong.\n\n#ProductivityTips #TechLife", hook: "POV: you just found this app" } },
+ { type: "image", platform: "instagram", format: "png", fileName: "instagram_stories_hook_a_1080x1920.png", dimensions: "1080x1920", status: "draft", metadata: { caption: "Swipe up to download free 🔥", hook: "Your workflow is about to change" } },
+ { type: "image", platform: "nextdoor", format: "png", fileName: "nextdoor_spotlight_1200x1200.png", dimensions: "1200x1200", status: "approved", metadata: { caption: "Your neighbors are already using this app to simplify their mornings. Join them — it's free.", hook: "Your neighbors love this app" } },
+ { type: "image", platform: "nextdoor", format: "png", fileName: "nextdoor_display_1200x628.png", dimensions: "1200x628", status: "draft", metadata: { caption: "The productivity app trusted by your community. Learn more.", hook: "Trusted locally" } },
+ { type: "video", platform: "instagram", format: "mp4", fileName: "instagram_reel_polished_1080x1920.mp4", dimensions: "1080x1920", status: "approved", metadata: { caption: "3 seconds to change your morning ⚡\n\nDownload free — link in bio.\n\n#Reels #ProductivityHack", hook: "Still doing it the hard way?", duration: "15s" } },
+ { type: "video", platform: "tiktok", format: "mp4", fileName: "tiktok_ad_authentic_1080x1920.mp4", dimensions: "1080x1920", status: "approved", metadata: { caption: "okay i HAVE to share this app with you guys 😭 #fyp #productivity #appoftiktok", hook: "I need to talk about this app", duration: "9s" } },
+ { type: "video", platform: "tiktok", format: "mp4", fileName: "tiktok_ad_hook_b_1080x1920.mp4", dimensions: "1080x1920", status: "draft", metadata: { caption: "why is nobody talking about this?? #fyp #lifehack", hook: "Why is nobody talking about this?", duration: "12s" } },
+ { type: "video", platform: "nextdoor", format: "mp4", fileName: "nextdoor_video_local_1080x1080.mp4", dimensions: "1080x1080", status: "draft", metadata: { caption: "See why your neighbors love this app. Simple, free, and made for busy mornings.", hook: "Your neighbors love this app", duration: "15s" } },
+ { type: "copy", platform: "instagram", format: "json", fileName: "instagram_captions.json", status: "approved", metadata: { caption: "5 caption variations for Instagram" } },
+ { type: "copy", platform: "tiktok", format: "json", fileName: "tiktok_captions.json", status: "approved", metadata: { caption: "5 caption variations for TikTok" } },
+ { type: "copy", platform: "nextdoor", format: "json", fileName: "nextdoor_posts.json", status: "approved", metadata: { caption: "3 post variations for Nextdoor" } },
+ { type: "research", platform: null, format: "html", fileName: "interactive_report.html", status: "approved", metadata: { caption: "Market research dashboard with competitor analysis" } },
+ { type: "script", platform: null, format: "md", fileName: "ad_scripts_all_platforms.md", status: "approved", metadata: { caption: "15 ad scripts — 5 hooks × 3 platform styles" } },
+ ];
+
+ for (const asset of assets) {
+ await prisma.asset.create({
+ data: {
+ campaignId: campaign1.id,
+ type: asset.type,
+ platform: asset.platform,
+ format: asset.format,
+ filePath: `outputs/spring_launch_campaign_20260323/${asset.type === "image" ? "ads" : asset.type === "video" ? "video" : asset.type === "copy" ? "copy" : ""}/${asset.fileName}`,
+ fileName: asset.fileName,
+ dimensions: asset.dimensions || null,
+ status: asset.status,
+ metadata: JSON.stringify(asset.metadata),
+ },
+ });
+ }
+
+ // Campaign 2: Running
+ const campaign2 = await prisma.campaign.create({
+ data: {
+ name: "Summer Feature Drop",
+ status: "running",
+ platforms: JSON.stringify(["instagram", "tiktok"]),
+ config: JSON.stringify({
+ goal: "engagement",
+ keyMessage: "The feature you've been asking for is finally here.",
+ variations: 3,
+ }),
+ },
+ });
+
+ // Partial agent runs for campaign 2
+ const partialAgents = [
+ { name: "trend-scout", status: "completed", duration: 9000, summary: "4 trending hooks found" },
+ { name: "marketing-research-agent", status: "completed", duration: 110000, summary: "Research complete. 5 angles identified." },
+ { name: "script-writer", status: "running", duration: null, summary: null },
+ ];
+
+ for (const agent of partialAgents) {
+ await prisma.agentRun.create({
+ data: {
+ campaignId: campaign2.id,
+ agentName: agent.name,
+ status: agent.status,
+ startedAt: new Date(),
+ completedAt: agent.status === "completed" ? new Date() : null,
+ durationMs: agent.duration,
+ outputSummary: agent.summary,
+ },
+ });
+ }
+
+ // Campaign 3: Draft
+ await prisma.campaign.create({
+ data: {
+ name: "Back to School Promo",
+ status: "draft",
+ platforms: JSON.stringify(["instagram", "nextdoor"]),
+ config: JSON.stringify({
+ goal: "app_downloads",
+ keyMessage: "Get organized before school starts. The app students and parents love.",
+ }),
+ },
+ });
+
+ // Campaign 4: Published
+ const campaign4 = await prisma.campaign.create({
+ data: {
+ name: "Valentine's Day Push",
+ status: "published",
+ platforms: JSON.stringify(["instagram", "tiktok"]),
+ config: JSON.stringify({
+ goal: "brand_awareness",
+ keyMessage: "Share the love — share the app.",
+ }),
+ },
+ });
+
+ // A few published assets for campaign 4
+ for (const asset of [
+ { fileName: "ig_valentines_1080x1080.png", platform: "instagram", type: "image", dimensions: "1080x1080" },
+ { fileName: "tiktok_valentines_1080x1920.mp4", platform: "tiktok", type: "video", dimensions: "1080x1920" },
+ ]) {
+ await prisma.asset.create({
+ data: {
+ campaignId: campaign4.id,
+ type: asset.type,
+ platform: asset.platform,
+ format: asset.fileName.split(".").pop()!,
+ filePath: `outputs/valentines_push_20260214/ads/${asset.fileName}`,
+ fileName: asset.fileName,
+ dimensions: asset.dimensions,
+ status: "published",
+ postizPostId: `postiz_${Math.random().toString(36).slice(2, 10)}`,
+ metadata: JSON.stringify({ caption: "Share the love 💕 Download free — link in bio." }),
+ },
+ });
+ }
+
+ // Trend report
+ await prisma.trendReport.create({
+ data: {
+ name: "Weekly Trends — March 17-23, 2026",
+ filePath: "outputs/trend_reports/weekly_20260323.html",
+ summary: "Top hooks: 'POV: you just found...', 'Why is nobody talking about...'. Competitor analysis: 3 apps gaining traction with UGC-style Reels. Recommended themes: morning routine, before/after transformations.",
+ },
+ });
+
+ await prisma.trendReport.create({
+ data: {
+ name: "Weekly Trends — March 10-16, 2026",
+ filePath: "outputs/trend_reports/weekly_20260316.html",
+ summary: "Trending format: split-screen comparisons. Rising hashtag: #AppTok. Competitor spotlight: rival app launched TikTok campaign with 2M views.",
+ },
+ });
+
+ console.log("Demo data seeded:");
+ console.log(" 4 campaigns (review, running, draft, published)");
+ console.log(" 14 assets on Spring Launch");
+ console.log(" 2 published assets on Valentine's");
+ console.log(" 7 completed agent runs + 3 partial");
+ console.log(" 2 trend reports");
+}
+
+main()
+ .catch((e) => {
+ console.error(e);
+ process.exit(1);
+ })
+ .finally(() => prisma.$disconnect());
diff --git a/prisma/seed.ts b/prisma/seed.ts
new file mode 100644
index 0000000..a72e182
--- /dev/null
+++ b/prisma/seed.ts
@@ -0,0 +1,32 @@
+import bcrypt from "bcryptjs";
+import { PrismaClient } from "../lib/generated/prisma/client";
+import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
+
+const adapter = new PrismaBetterSqlite3({
+ url: process.env.DATABASE_URL || "file:./data/marketing.db",
+});
+const prisma = new PrismaClient({ adapter });
+
+async function main() {
+ const email = process.env.ADMIN_EMAIL || "admin@localhost";
+ const password = process.env.ADMIN_PASSWORD || "admin123";
+
+ await prisma.user.upsert({
+ where: { email },
+ update: {},
+ create: {
+ email,
+ password: await bcrypt.hash(password, 12),
+ name: "Admin",
+ },
+ });
+
+ console.log(`Admin user created: ${email}`);
+}
+
+main()
+ .catch((e) => {
+ console.error(e);
+ process.exit(1);
+ })
+ .finally(() => prisma.$disconnect());
diff --git a/tsconfig.json b/tsconfig.json
index 3a13f90..5c4b573 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -30,5 +30,5 @@
".next/dev/types/**/*.ts",
"**/*.mts"
],
- "exclude": ["node_modules"]
+ "exclude": ["node_modules", "pipeline/remotion-ad"]
}