feat: complete marketing command center with pipeline, UI, and asset generation

- Dashboard with campaign management, asset gallery, and publishing queue
- 7-agent pipeline: trend scout, research, scripts, ad creative, video, copy, distribution
- Campaign form with screenshot upload, goal picker, platform selection
- Campaign detail view with Details/Pipeline/Assets/Chat tabs
- Two-set image generation: Gemini AI (NanoBanana MCP) + Canvas Design posters
- Remotion video rendering with phone.png frame and real screenshot alignment
- honeyDue branding: blue #0079FF, orange #FF9400, Inter font, warm off-white
- Asset cards with source badges (Gemini/Canvas/Remotion/Playwright)
- Markdown/JSON render endpoint for viewing pipeline outputs as HTML
- Settings page with Tavily, Gemini, Postiz, Nextdoor integration management
- Claude Chat for campaign feedback loop with streaming SSE
- Postiz publishing modal with scheduling
- Auth with NextAuth credentials + JWT sessions
- SQLite via Prisma with better-sqlite3 adapter

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-03-23 21:05:26 -05:00
parent 6b08cfb73a
commit 66c2bbec8b
113 changed files with 12741 additions and 138 deletions
+175
View File
@@ -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<AdProps> = ({
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 (
<AbsoluteFill
style={{
backgroundColor: config.bg,
fontFamily: config.fontFamily,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: 80,
}}
>
{/* Hook */}
<div
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: `translate(-50%, -50%) scale(${hookScale})`,
opacity: hookOpacity,
fontSize: 72,
fontWeight: 800,
color: config.text,
textAlign: "center",
lineHeight: 1.2,
maxWidth: "80%",
}}
>
{hookText}
</div>
{/* Body */}
<div
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
opacity: bodyOpacity,
fontSize: 48,
fontWeight: 600,
color: config.text,
textAlign: "center",
lineHeight: 1.3,
maxWidth: "80%",
}}
>
{bodyText}
</div>
{/* Proof */}
<div
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
opacity: proofOpacity,
fontSize: 36,
fontWeight: 500,
color: config.accent,
textAlign: "center",
}}
>
{proofText}
</div>
{/* CTA */}
<div
style={{
position: "absolute",
bottom: "15%",
left: "50%",
transform: `translateX(-50%) scale(${ctaScale})`,
opacity: ctaOpacity,
backgroundColor: config.accent,
color: "#fff",
fontSize: 32,
fontWeight: 700,
padding: "20px 60px",
borderRadius: 16,
textAlign: "center",
}}
>
{ctaText}
</div>
</AbsoluteFill>
);
};
+318
View File
@@ -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<HoneyDueAdProps> = ({
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 (
<AbsoluteFill
style={{
backgroundColor: bg,
fontFamily:
'-apple-system, BlinkMacSystemFont, "SF Pro Display", "Inter", sans-serif',
}}
>
{/* Subtle gradient overlay */}
<div
style={{
position: "absolute",
inset: 0,
background: `radial-gradient(ellipse at 50% 30%, ${COLORS.primary}15 0%, transparent 60%)`,
}}
/>
{/* === HOOK === */}
<div
style={{
position: "absolute",
top: "38%",
left: "50%",
transform: `translate(-50%, -50%) translateY(${hookY}px)`,
opacity: hookOpacity,
width: "85%",
textAlign: "center",
}}
>
<div
style={{
fontSize: 68,
fontWeight: 800,
color: COLORS.white,
lineHeight: 1.15,
letterSpacing: -1,
}}
>
{hookText}
</div>
</div>
{/* === PHONE + BODY TEXT === */}
<div
style={{
position: "absolute",
top: "8%",
left: "50%",
transform: `translateX(-50%) scale(${phoneScale})`,
opacity: phoneOpacity,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 30,
}}
>
{/* Body text above phone */}
<div
style={{
opacity: bodyTextOpacity,
fontSize: 36,
fontWeight: 600,
color: COLORS.white,
textAlign: "center",
maxWidth: width * 0.8,
lineHeight: 1.3,
}}
>
{bodyText}
</div>
{/* Phone mockup — real phone.png frame over screenshot */}
<div
style={{
width: phoneWidth,
height: phoneHeight,
position: "relative",
filter: "drop-shadow(0 30px 60px rgba(0,0,0,0.5))",
}}
>
{/* Screenshot behind the frame — clipped to screen area */}
<div
style={{
position: "absolute",
top: "3.2%",
left: "4.2%",
width: "91.6%",
height: "93.6%",
borderRadius: phoneWidth * 0.065,
overflow: "hidden",
}}
>
<Img
src={screenshotSrc}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
objectPosition: "top center",
}}
/>
</div>
{/* Phone frame on top */}
<Img
src={staticFile("phone.png")}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
objectFit: "contain",
}}
/>
</div>
</div>
{/* === PROOF === */}
<div
style={{
position: "absolute",
top: "42%",
left: "50%",
transform: `translate(-50%, -50%) scale(${proofScale})`,
opacity: proofOpacity,
textAlign: "center",
}}
>
<div
style={{
fontSize: 44,
fontWeight: 700,
color: COLORS.accent,
marginBottom: 16,
}}
>
{proofText}
</div>
<div
style={{
fontSize: 28,
fontWeight: 500,
color: "rgba(255,255,255,0.7)",
}}
>
Join thousands of homeowners
</div>
</div>
{/* === CTA === */}
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
opacity: ctaOpacity,
transform: `scale(${ctaScale})`,
gap: 40,
}}
>
{/* Brand name header */}
<div
style={{
fontSize: 56,
fontWeight: 800,
color: COLORS.white,
letterSpacing: -1,
textAlign: "center",
}}
>
honeyDue
</div>
{/* Icon — 50% of canvas width, centered */}
<Img
src={staticFile("icon.png")}
style={{
width: width * 0.5,
height: "auto",
borderRadius: 32,
}}
/>
{/* CTA button */}
<div
style={{
backgroundColor: COLORS.primary,
color: COLORS.white,
fontSize: 36,
fontWeight: 700,
padding: "24px 72px",
borderRadius: 20,
textAlign: "center",
boxShadow: `0 8px 32px ${COLORS.primary}80`,
transform: `scale(${ctaPulse})`,
}}
>
{ctaText}
</div>
</div>
</AbsoluteFill>
);
};
+146
View File
@@ -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 === */}
<Composition
id="Gemini-IG-Feed-Cost"
component={HoneyDueAd}
durationInFrames={IG_FRAMES}
fps={30}
width={1080}
height={1920}
defaultProps={{
platform: "instagram" as const,
hookText: "A $10 filter.\nA $4,200 repair.",
bodyText: "honeyDue tracks every task so you never skip the small stuff.",
ctaText: "Download Free",
proofText: "Trusted by thousands of homeowners",
screenshotSrc: SCREENSHOT,
}}
/>
<Composition
id="Gemini-IG-Stories-FirstTimer"
component={HoneyDueAd}
durationInFrames={IG_FRAMES}
fps={30}
width={1080}
height={1920}
defaultProps={{
platform: "instagram" as const,
hookText: "Just bought my first house.\nNobody told me about all this.",
bodyText: "This app tells you what to fix and when.",
ctaText: "Try honeyDue",
proofText: "First-time homeowners love this",
screenshotSrc: SCREENSHOT,
}}
/>
<Composition
id="Gemini-TT-SilentTodo"
component={HoneyDueAd}
durationInFrames={TT_FRAMES}
fps={30}
width={1080}
height={1920}
defaultProps={{
platform: "tiktok" as const,
hookText: "Your house has a\nhidden to-do list.",
bodyText: "HVAC filters. Gutters. Water heater. honeyDue sees it all.",
ctaText: "Get Started Free",
proofText: "Never miss maintenance again",
screenshotSrc: SCREENSHOT,
}}
/>
<Composition
id="Gemini-TT-Forgetter"
component={HoneyDueAd}
durationInFrames={TT_FRAMES}
fps={30}
width={1080}
height={1920}
defaultProps={{
platform: "tiktok" as const,
hookText: "I forgot for 2 years.",
bodyText: "honeyDue would have reminded me 8 times by now.",
ctaText: "Download Free",
proofText: "Your home maintenance safety net",
screenshotSrc: SCREENSHOT,
}}
/>
{/* === CANVAS POSTER VIDEOS === */}
<Composition
id="Poster-IG-Feed-Cost"
component={HoneyDueAd}
durationInFrames={IG_FRAMES}
fps={30}
width={1080}
height={1920}
defaultProps={{
platform: "instagram" as const,
hookText: "A $10 filter.\nA $4,200 repair.",
bodyText: "The difference between a reminder and a disaster.",
ctaText: "Download Free",
proofText: "Join thousands of organized homeowners",
screenshotSrc: SCREENSHOT,
}}
/>
<Composition
id="Poster-IG-Stories-FirstHouse"
component={HoneyDueAd}
durationInFrames={IG_FRAMES}
fps={30}
width={1080}
height={1920}
defaultProps={{
platform: "instagram" as const,
hookText: "Just bought my first house.",
bodyText: "100+ maintenance tasks. One app to track them all.",
ctaText: "Try honeyDue",
proofText: "Built for first-time homeowners",
screenshotSrc: SCREENSHOT,
}}
/>
<Composition
id="Poster-TT-HiddenTodo"
component={HoneyDueAd}
durationInFrames={TT_FRAMES}
fps={30}
width={1080}
height={1920}
defaultProps={{
platform: "tiktok" as const,
hookText: "Your house has a\nhidden to-do list.",
bodyText: "Filters. Gutters. Drains. Vents. honeyDue tracks all of it.",
ctaText: "Get Started Free",
proofText: "See what you've been missing",
screenshotSrc: SCREENSHOT,
}}
/>
<Composition
id="Poster-TT-HVAC2Years"
component={HoneyDueAd}
durationInFrames={TT_FRAMES}
fps={30}
width={1080}
height={1920}
defaultProps={{
platform: "tiktok" as const,
hookText: "I forgot my HVAC filter\nfor 2 years.",
bodyText: "That's 8 missed reminders honeyDue would have sent.",
ctaText: "Download Free",
proofText: "Never forget again",
screenshotSrc: SCREENSHOT,
}}
/>
</>
);
};
+4
View File
@@ -0,0 +1,4 @@
import { registerRoot } from "remotion";
import { RemotionRoot } from "./Root";
registerRoot(RemotionRoot);