-
- OpenReply
+
+
{selectedTemplate
diff --git a/app/manifest.ts b/app/manifest.ts
index 31556a60..f7eb7a5c 100644
--- a/app/manifest.ts
+++ b/app/manifest.ts
@@ -6,18 +6,18 @@ import type { MetadataRoute } from "next";
// a phone practical.
export default function manifest(): MetadataRoute.Manifest {
return {
- name: "OpenReply",
- short_name: "OpenReply",
+ name: "BeReply",
+ short_name: "BeReply",
description: "Instagram comment-to-DM automation",
start_url: "/overview",
display: "standalone",
orientation: "portrait",
- background_color: "#18181b",
- theme_color: "#18181b",
+ background_color: "#050C29",
+ theme_color: "#0c3bb9",
icons: [
- { src: "/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
- { src: "/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" },
- { src: "/icon-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
+ { src: "/icon-192", sizes: "192x192", type: "image/png", purpose: "any" },
+ { src: "/icon-512", sizes: "512x512", type: "image/png", purpose: "any" },
+ { src: "/icon-512", sizes: "512x512", type: "image/png", purpose: "maskable" },
],
};
}
diff --git a/app/page.tsx b/app/page.tsx
index 2276552a..73bcb0cd 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,508 +1,7 @@
-import type { Metadata } from "next";
-import type { ReactNode } from "react";
-import Link from "next/link";
-import { DemoNotice } from "@/components/demo-notice";
+import { redirect } from "next/navigation";
+import { auth } from "@/lib/auth";
-export const metadata: Metadata = {
- title: "OpenReply - Open source Instagram comment-to-DM automation",
- description:
- "A free, self-hosted ManyChat alternative. Turn Instagram keyword comments into automatic private replies using the official Meta API.",
-};
-
-const GITHUB_URL = "https://github.com/diwenne/openreply";
-const SETUP_DOCS_URL =
- "https://github.com/diwenne/openreply/blob/main/docs/setup.md";
-
-function formatStars(count: number): string {
- if (count >= 1000) {
- return `${(count / 1000).toFixed(1)}K`;
- }
- return count.toLocaleString();
-}
-
-const githubIconPath =
- "M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0016 8c0-4.42-3.58-8-8-8z";
-
-const heroStats = [
- { value: "24/7", label: "Comment monitoring" },
- { value: "1", label: "DM per matched comment" },
- { value: "0", label: "Scraping required" },
-];
-
-const flowSteps = [
- {
- eyebrow: "Connect",
- title: "Link your Instagram professional account",
- description:
- "Sign in by email and connect Instagram once. No password sharing, no browser automation.",
- },
- {
- eyebrow: "Build",
- title: "Pick a post, keywords, and the DM",
- description:
- "Create a campaign for a reel or post: the keyword to watch, the public reply, and the DM to send.",
- },
- {
- eyebrow: "Deliver",
- title: "Replies go out through the official API",
- description:
- "Webhooks catch comments instantly and a polling sweep catches the ones Instagram never pushes, so nothing is missed. Every send is queued, rate-limited, and logged.",
- },
-];
-
-const features = [
- "Email magic-link sign-in",
- "Multiple Instagram accounts",
- "Encrypted tokens at rest",
- "Webhook + polling reconciliation",
- "Queue-backed delivery worker",
- "Per-account rate limiting",
- "Tracked links with click stats",
- "DM logs with full status",
- "No plan limits, fully self-hosted",
-];
-
-/* Static, faithful copies of the real Overview and Dashboard screens, built in
- the app's own design tokens so what visitors see is what the app looks like. */
-
-function AppWindow({ label, children }: { label: string; children: ReactNode }) {
- return (
-
-
-
-
-
- {label}
-
-
{children}
-
- );
-}
-
-function Stat({ label, value }: { label: string; value: string }) {
- return (
-
- );
-}
-
-const overviewStats = [
- ["Views", "847.2K"],
- ["Reach", "612.4K"],
- ["Likes", "38.1K"],
- ["Comments", "4,204"],
- ["Saved", "9,712"],
- ["Shares", "2,340"],
-];
-
-const overviewPosts = [
- ["Spring drop reel", "214.8K", "9.1K", "Apr 3"],
- ["Restock haul", "88.4K", "5.2K", "Mar 28"],
- ["Behind the studio", "51.3K", "3.4K", "Mar 21"],
-];
-
-function OverviewPreview() {
- return (
-
-
-
-
Overview
-
- Recent — 24 posts from @studio.store
-
-
-
- Last 50
-
-
-
-
- {overviewStats.map(([label, value]) => (
-
- ))}
-
-
-
-
-
- Followers over time
-
-
- 48,210 +1,240 · 30d
-
-
-
-
-
-
-
-
-
Posts
-
-
-
- Post
- Views
- Likes
- Date
-
-
-
- {overviewPosts.map(([post, views, likes, date]) => (
-
- {post}
- {views}
- {likes}
- {date}
-
- ))}
-
-
-
-
- );
-}
-
-function MatchedCommentCard() {
- return (
-
-
New comment
-
@maya.co
-
LINK please
-
-
- Matched GUIDE
-
-
- Queued private reply
-
-
-
- );
-}
-
-const dashboardStats = [
- ["Active Campaigns", "8"],
- ["DMs Sent", "1,284"],
- ["Skipped", "42"],
- ["Failed", "3"],
- ["Clicks", "356"],
- ["CTR", "27.7%"],
-];
-
-const dashboardChart: [string, number][] = [
- ["Mon", 42],
- ["Tue", 68],
- ["Wed", 51],
- ["Thu", 94],
- ["Fri", 120],
- ["Sat", 86],
- ["Sun", 73],
-];
-
-const dashboardActivity = [
- ["@maya.co", "Product guide reply", "Sent", "text-success"],
- ["@founder.ray", "Price request", "Sent", "text-success"],
- ["@shop.ava", "Lead magnet", "Queued", "text-warning"],
-];
-
-function DashboardPreview() {
- const maxDM = Math.max(...dashboardChart.map(([, n]) => n));
- return (
-
- Hello, Maya!
- 2 connected accounts · 340 contacts
-
-
- {dashboardStats.map(([label, value]) => (
-
- ))}
-
-
-
-
DMs — Last 7 Days
-
- {dashboardChart.map(([day, n]) => (
-
- ))}
-
-
-
-
-
Recent Activity
-
- {dashboardActivity.map(([user, automation, status, color]) => (
-
- {user}
- {automation}
- {status}
-
- ))}
-
-
-
- );
-}
-
-async function getGitHubStars(): Promise
{
- try {
- const res = await fetch("https://api.github.com/repos/diwenne/openreply", {
- headers: { Accept: "application/vnd.github+json" },
- next: { revalidate: 3600 },
- });
- if (!res.ok) return null;
- const data = (await res.json()) as { stargazers_count?: number };
- return typeof data.stargazers_count === "number" ? data.stargazers_count : null;
- } catch {
- return null;
- }
-}
-
-export default async function Home() {
- const stars = await getGitHubStars();
- return (
-
-
-
-
-
-
-
-
- Open source · Official Meta API
-
-
-
- Make every comment start the right DM
-
-
-
- Open-sourced ManyChat. When someone comments your keyword on a post
- or reel, they get your DM a second later. Free, self-hosted, and
- built on the official Instagram API.
-
-
-
-
-
- {heroStats.map((stat) => (
-
-
{stat.value}
- {stat.label}
-
- ))}
-
-
-
-
-
-
-
-
-
-
How it works
-
- A comment in, a DM out
-
-
- Three steps. Connect an account, build a campaign, and let it run.
- The webhook handles it live and the poll sweeps up whatever the
- webhook misses.
-
-
-
-
- {flowSteps.map((step) => (
-
- {step.eyebrow}
-
-
{step.title}
-
{step.description}
-
-
- ))}
-
-
-
-
-
-
-
-
-
-
The dashboard
-
- See exactly what happened
-
-
- Every comment event is traceable: queued, matched, sent, skipped,
- failed, or rate-limited. No black box.
-
-
-
-
-
-
-
-
What’s included
-
- Everything, no tiers
-
-
- It is self-hosted and open source, so there is nothing to unlock. You
- run it, you own it.
-
-
-
-
- {features.map((feature) => (
-
- {feature}
-
- ))}
-
-
-
-
-
-
-
- Turn your next reel’s comments into DMs
-
-
- Free and open source. Star it if it saves you a subscription.
-
-
-
- On your own deployment, not this one.
- {" "}
- Clone the repo and follow the{" "}
-
- setup guide
- {" "}
- — a Meta app and a domain of your own are required before anything
- sends.
-
-
-
-
-
-
-
-
- );
+export default async function RootPage() {
+ const session = await auth();
+ redirect(session?.user?.id ? "/dashboard" : "/login");
}
diff --git a/app/verify-request/page.tsx b/app/verify-request/page.tsx
index 8606c32a..c4d3e1a7 100644
--- a/app/verify-request/page.tsx
+++ b/app/verify-request/page.tsx
@@ -1,7 +1,8 @@
import Link from "next/link";
+import { BrandMark } from "@/components/brand-mark";
export const metadata = {
- title: "Check your email - OpenReply",
+ title: "Check your email - BeReply",
description: "A sign-in link was sent to your email.",
};
@@ -10,8 +11,8 @@ export default function VerifyRequestPage() {
-
- OpenReply
+
+
diff --git a/components/brand-mark.tsx b/components/brand-mark.tsx
new file mode 100644
index 00000000..af1f53dc
--- /dev/null
+++ b/components/brand-mark.tsx
@@ -0,0 +1,36 @@
+/**
+ * BeReply wordmark — Believe brand system §3: "Be" prefix in Cyan 400,
+ * descriptor in Believe Blue 700 (Paper 50 in the negative variant, for
+ * dark/blue surfaces), set in Fraunces 500. Closing dot is Cyan 400,
+ * the same signature device that closes the house "Believe." wordmark.
+ */
+export function BrandMark({
+ className = "",
+ variant = "positive",
+}: {
+ className?: string;
+ variant?: "positive" | "negative";
+}) {
+ const descriptorColor = variant === "negative" ? "#fafaf7" : "#0c3bb9";
+ return (
+
+ Be
+ Reply
+
+
+ );
+}
diff --git a/components/campaign-ai-settings.tsx b/components/campaign-ai-settings.tsx
new file mode 100644
index 00000000..32d12659
--- /dev/null
+++ b/components/campaign-ai-settings.tsx
@@ -0,0 +1,95 @@
+"use client";
+
+/**
+ * Campaign AI Settings
+ *
+ * Toggle + brand system prompt for the AI auto-responder (lib/ai/responder.ts).
+ * Controlled component — no fetch, no persistence. The caller owns state and
+ * saving; this just reports the next { aiEnabled, aiConfig } on every change.
+ */
+
+interface AiConfig {
+ systemPrompt?: string;
+}
+
+interface CampaignAiSettingsProps {
+ aiEnabled: boolean;
+ aiConfig: AiConfig | null;
+ onChange: (next: {
+ aiEnabled: boolean;
+ aiConfig: { systemPrompt: string } | null;
+ }) => void;
+}
+
+function Toggle({ on, onToggle }: { on: boolean; onToggle: () => void }) {
+ return (
+
+
+
+ );
+}
+
+export default function CampaignAiSettings({
+ aiEnabled,
+ aiConfig,
+ onChange,
+}: CampaignAiSettingsProps) {
+ const systemPrompt = aiConfig?.systemPrompt ?? "";
+
+ return (
+
+
AI
+
+
+
+
Responder con IA
+
+ Genera cada respuesta con IA en base a la conversación, en vez de
+ enviar siempre el mismo mensaje.
+
+
+
+ onChange({
+ aiEnabled: !aiEnabled,
+ aiConfig: { systemPrompt },
+ })
+ }
+ />
+
+ {aiEnabled && (
+
+ )}
+
+
+ );
+}
diff --git a/components/campaign-builder.tsx b/components/campaign-builder.tsx
index ba97b280..c1b0dc81 100644
--- a/components/campaign-builder.tsx
+++ b/components/campaign-builder.tsx
@@ -17,6 +17,7 @@ import { useRouter } from "next/navigation";
import AccountSelect, { type AccountOption } from "@/components/account-select";
import PostPicker from "@/components/post-picker";
import CampaignPreview, { type PreviewTab } from "@/components/campaign-preview";
+import CampaignAiSettings from "@/components/campaign-ai-settings";
import { readCache, writeCache } from "@/lib/client-cache";
import {
IMPORT_QUEUE_KEY,
@@ -54,6 +55,8 @@ interface LoadedCampaign {
isActive: boolean;
instagramAccountId: string;
trackedLinks?: { destinationUrl: string; label?: string | null }[];
+ aiEnabled: boolean;
+ aiConfig: { systemPrompt?: string } | null;
}
interface CampaignBuilderProps {
@@ -181,6 +184,11 @@ export default function CampaignBuilder({ mode, campaignId }: CampaignBuilderPro
const [followUpMessage, setFollowUpMessage] = useState("");
const [followUpDelayMinutes, setFollowUpDelayMinutes] = useState(0);
+ const [aiEnabled, setAiEnabled] = useState(false);
+ const [aiConfig, setAiConfig] = useState<{ systemPrompt?: string } | null>(
+ null
+ );
+
const [previewTab, setPreviewTab] = useState
("dm");
// CSV import queue. When present, each save advances to the next row instead
@@ -271,6 +279,8 @@ export default function CampaignBuilder({ mode, campaignId }: CampaignBuilderPro
setOpeningDmMessage(c.openingDmMessage ?? "");
setOpeningDmButtonLabel(c.openingDmButtonLabel ?? "");
setDmMessage(c.dmMessage);
+ setAiEnabled(c.aiEnabled);
+ setAiConfig(c.aiConfig);
setLinkButtonLabel(c.linkButtonLabel ?? "Open link");
setIsActive(c.isActive);
const link = c.trackedLinks?.[0]?.destinationUrl ?? "";
@@ -408,6 +418,8 @@ export default function CampaignBuilder({ mode, campaignId }: CampaignBuilderPro
keywords: matchMode === "any" ? [] : keywords,
dmTriggerEnabled,
dmMessage,
+ aiEnabled,
+ aiConfig,
openingDmEnabled,
openingDmMessage: openingDmEnabled ? openingDmMessage : null,
openingDmButtonLabel: openingDmEnabled ? openingDmButtonLabel : null,
@@ -979,6 +991,25 @@ export default function CampaignBuilder({ mode, campaignId }: CampaignBuilderPro
)}
+
+
+ {
+ setAiEnabled(next.aiEnabled);
+ setAiConfig(next.aiConfig);
+ }}
+ />
+ {mode === "edit" && campaignId && (
+
+ Open flow builder →
+
+ )}
+
{/* Right: preview */}
diff --git a/components/contact-tags.tsx b/components/contact-tags.tsx
new file mode 100644
index 00000000..5c868e9f
--- /dev/null
+++ b/components/contact-tags.tsx
@@ -0,0 +1,106 @@
+"use client";
+
+import { useState, type FormEvent } from "react";
+
+interface ContactTagsProps {
+ contactId: string;
+ username: string | null;
+ igUserId: string;
+ tags: string[];
+}
+
+export default function ContactTags({
+ contactId,
+ username,
+ igUserId,
+ tags: initialTags,
+}: ContactTagsProps) {
+ const [tags, setTags] = useState(initialTags);
+ const [input, setInput] = useState("");
+ const [pending, setPending] = useState(false);
+
+ async function handleAddTag(e: FormEvent) {
+ e.preventDefault();
+ const tag = input.trim();
+ if (!tag || tags.includes(tag) || pending) return;
+
+ setPending(true);
+ setInput("");
+
+ try {
+ const res = await fetch(`/api/contacts/${contactId}/tags`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ tag }),
+ });
+ const data = await res.json();
+ if (data.success) setTags(data.data.tags);
+ } catch (err) {
+ console.error("Failed to add tag:", err);
+ } finally {
+ setPending(false);
+ }
+ }
+
+ async function handleRemoveTag(tag: string) {
+ const previousTags = tags;
+ setTags((prev) => prev.filter((t) => t !== tag));
+
+ try {
+ const res = await fetch(`/api/contacts/${contactId}/tags`, {
+ method: "DELETE",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ tag }),
+ });
+ const data = await res.json();
+ if (data.success) {
+ setTags(data.data.tags);
+ } else {
+ setTags(previousTags);
+ }
+ } catch (err) {
+ console.error("Failed to remove tag:", err);
+ setTags(previousTags);
+ }
+ }
+
+ return (
+
+
+
+ {username ? `@${username}` : igUserId}
+
+ {username &&
{igUserId}
}
+
+
+
+ {tags.map((tag) => (
+
+ {tag}
+ handleRemoveTag(tag)}
+ aria-label={`Remove tag ${tag}`}
+ className="leading-none text-muted hover:text-error"
+ >
+ ×
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/components/flow-builder/flow-builder.tsx b/components/flow-builder/flow-builder.tsx
new file mode 100644
index 00000000..6791ea05
--- /dev/null
+++ b/components/flow-builder/flow-builder.tsx
@@ -0,0 +1,410 @@
+"use client";
+
+/**
+ * Flow Builder
+ *
+ * Canvas alternative to the simple keyword → DM form. A campaign with no nodes
+ * keeps running in legacy mode, so saving an empty canvas is what clears the
+ * flow and hands the campaign back to the simple path.
+ */
+
+import "@xyflow/react/dist/style.css";
+
+import { useCallback, useState } from "react";
+import {
+ addEdge,
+ Background,
+ BackgroundVariant,
+ Controls,
+ MarkerType,
+ MiniMap,
+ ReactFlow,
+ useEdgesState,
+ useNodesState,
+ type Connection,
+ type Edge,
+ type NodeTypes,
+} from "@xyflow/react";
+import FlowNodeCard, { type FlowCanvasNode } from "./flow-node";
+import {
+ CONDITION_FIELDS,
+ CONDITION_OPERATORS,
+ type FlowEdge,
+ type FlowNode,
+ type FlowNodeData,
+ type FlowNodeType,
+} from "@/lib/flow/engine";
+
+const nodeTypes: NodeTypes = {
+ condition: FlowNodeCard,
+ send_dm: FlowNodeCard,
+ add_tag: FlowNodeCard,
+ ai_reply: FlowNodeCard,
+};
+
+const PALETTE: { type: FlowNodeType; label: string }[] = [
+ { type: "condition", label: "Condition" },
+ { type: "send_dm", label: "Send DM" },
+ { type: "add_tag", label: "Add tag" },
+ { type: "ai_reply", label: "AI reply" },
+];
+
+const NEW_NODE_DATA: Record = {
+ condition: { field: "incomingText", operator: "includes", value: "" },
+ send_dm: { message: "" },
+ add_tag: { tag: "" },
+ ai_reply: { systemPrompt: "" },
+};
+
+const FIELD_LABELS: Record<(typeof CONDITION_FIELDS)[number], string> = {
+ tags: "Contact tags",
+ incomingText: "Message text",
+};
+
+const OPERATOR_LABELS: Record<(typeof CONDITION_OPERATORS)[number], string> = {
+ includes: "includes",
+ not_includes: "does not include",
+};
+
+const inputClass =
+ "w-full rounded border border-border bg-background px-2.5 py-1.5 text-sm text-foreground focus:border-accent/40 focus:outline-none";
+
+function toFlowNodes(nodes: FlowCanvasNode[]): FlowNode[] {
+ return nodes.flatMap((node) =>
+ node.type
+ ? [
+ {
+ id: node.id,
+ type: node.type,
+ data: node.data,
+ position: { x: node.position.x, y: node.position.y },
+ },
+ ]
+ : []
+ );
+}
+
+function toFlowEdges(edges: Edge[]): FlowEdge[] {
+ return edges.map((edge) => ({
+ id: edge.id,
+ source: edge.source,
+ target: edge.target,
+ ...(edge.sourceHandle ? { sourceHandle: edge.sourceHandle } : {}),
+ }));
+}
+
+function readString(value: unknown): string {
+ return typeof value === "string" ? value : "";
+}
+
+export type FlowBuilderProps = {
+ initialNodes: FlowNode[];
+ initialEdges: FlowEdge[];
+ onSave: (nodes: FlowNode[], edges: FlowEdge[]) => Promise;
+};
+
+export default function FlowBuilder({
+ initialNodes,
+ initialEdges,
+ onSave,
+}: FlowBuilderProps) {
+ const [nodes, setNodes, onNodesChange] =
+ useNodesState(initialNodes);
+ const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
+ const [selectedId, setSelectedId] = useState(null);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+ const [savedAt, setSavedAt] = useState(null);
+
+ const onConnect = useCallback(
+ (connection: Connection) => setEdges((eds) => addEdge(connection, eds)),
+ [setEdges]
+ );
+
+ const addNode = useCallback(
+ (type: FlowNodeType) => {
+ const id = crypto.randomUUID();
+ setNodes((nds) => [
+ ...nds,
+ {
+ id,
+ type,
+ data: { ...NEW_NODE_DATA[type] },
+ position: { x: 120 + (nds.length % 3) * 40, y: 60 + nds.length * 120 },
+ },
+ ]);
+ setSelectedId(id);
+ },
+ [setNodes]
+ );
+
+ const updateNodeData = useCallback(
+ (id: string, patch: FlowNodeData) => {
+ setNodes((nds) =>
+ nds.map((node) =>
+ node.id === id ? { ...node, data: { ...node.data, ...patch } } : node
+ )
+ );
+ },
+ [setNodes]
+ );
+
+ const removeNode = useCallback(
+ (id: string) => {
+ setNodes((nds) => nds.filter((node) => node.id !== id));
+ setEdges((eds) => eds.filter((e) => e.source !== id && e.target !== id));
+ setSelectedId(null);
+ },
+ [setEdges, setNodes]
+ );
+
+ async function handleSave() {
+ setSaving(true);
+ setError(null);
+ try {
+ await onSave(toFlowNodes(nodes), toFlowEdges(edges));
+ setSavedAt(new Date().toLocaleTimeString());
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Could not save the flow");
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ const selected = nodes.find((node) => node.id === selectedId) ?? null;
+
+ return (
+
+
+
+ Add node
+ {PALETTE.map((item) => (
+ addNode(item.type)}
+ className="rounded border border-border px-2.5 py-1 text-xs text-muted hover:border-border-hover hover:text-foreground"
+ >
+ {item.label}
+
+ ))}
+
+
+ {savedAt && !error && (
+ Saved at {savedAt}
+ )}
+
+ {saving ? "Saving…" : "Save"}
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ setSelectedId(node.id)}
+ onPaneClick={() => setSelectedId(null)}
+ defaultEdgeOptions={{
+ type: "smoothstep",
+ markerEnd: { type: MarkerType.ArrowClosed },
+ }}
+ proOptions={{ hideAttribution: false }}
+ fitView
+ >
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function NodeInspector({
+ node,
+ onChange,
+ onRemove,
+}: {
+ node: FlowCanvasNode;
+ onChange: (patch: FlowNodeData) => void;
+ onRemove: () => void;
+}) {
+ return (
+
+
+
+ {PALETTE.find((item) => item.type === node.type)?.label ?? "Node"}
+
+
+ Remove
+
+
+
+ {node.type === "condition" && (
+ <>
+
+ onChange({ field: e.target.value })}
+ className={inputClass}
+ >
+ {CONDITION_FIELDS.map((field) => (
+
+ {FIELD_LABELS[field]}
+
+ ))}
+
+
+
+ onChange({ operator: e.target.value })}
+ className={inputClass}
+ >
+ {CONDITION_OPERATORS.map((operator) => (
+
+ {OPERATOR_LABELS[operator]}
+
+ ))}
+
+
+
+ onChange({ value: e.target.value })}
+ placeholder={node.data.field === "tags" ? "customer" : "price"}
+ className={inputClass}
+ />
+
+ >
+ )}
+
+ {node.type === "send_dm" && (
+
+
+ )}
+
+ {node.type === "add_tag" && (
+
+ onChange({ tag: e.target.value })}
+ placeholder="customer"
+ className={inputClass}
+ />
+
+ )}
+
+ {node.type === "ai_reply" && (
+
+
+ )}
+
+ );
+}
+
+function Field({
+ label,
+ children,
+}: {
+ label: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+ {label}
+ {children}
+
+ );
+}
+
+/**
+ * Wires the builder to the flow API for one campaign. Lives here so the flow
+ * page can stay a server component and still hand the canvas a save handler.
+ */
+export function AutomationFlowEditor({
+ automationId,
+ initialNodes,
+ initialEdges,
+}: {
+ automationId: string;
+ initialNodes: FlowNode[];
+ initialEdges: FlowEdge[];
+}) {
+ const onSave = useCallback(
+ async (savedNodes: FlowNode[], savedEdges: FlowEdge[]) => {
+ const response = await fetch(`/api/automations/${automationId}/flow`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ nodes: savedNodes, edges: savedEdges }),
+ });
+ const payload = (await response.json()) as {
+ success?: boolean;
+ error?: string;
+ };
+ if (!payload.success) {
+ throw new Error(payload.error ?? "Could not save the flow");
+ }
+ },
+ [automationId]
+ );
+
+ return (
+
+ );
+}
diff --git a/components/flow-builder/flow-node.tsx b/components/flow-builder/flow-node.tsx
new file mode 100644
index 00000000..0856e50d
--- /dev/null
+++ b/components/flow-builder/flow-node.tsx
@@ -0,0 +1,183 @@
+"use client";
+
+/**
+ * Flow Node
+ *
+ * One card on the flow canvas. Registered under all four flow node types, so
+ * the xyflow node `type` is the domain type — no extra `data.nodeType` mirror
+ * to keep in sync with what the engine reads.
+ */
+
+import { memo } from "react";
+import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
+import type { FlowNodeData, FlowNodeType } from "@/lib/flow/engine";
+
+export type FlowCanvasNode = Node;
+
+const HANDLE_SIZE = 9;
+
+const handleStyle = (color: string) => ({
+ width: HANDLE_SIZE,
+ height: HANDLE_SIZE,
+ borderRadius: 2,
+ background: color,
+ border: "1px solid var(--color-background)",
+});
+
+function ConditionIcon() {
+ return (
+
+
+
+
+
+ );
+}
+
+function SendDmIcon() {
+ return (
+
+
+
+ );
+}
+
+function AddTagIcon() {
+ return (
+
+
+
+
+ );
+}
+
+function AiReplyIcon() {
+ return (
+
+
+
+ );
+}
+
+function Icon({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+type NodeConfig = {
+ label: string;
+ bar: string;
+ tone: string;
+ icon: () => React.ReactElement;
+ subtitle: (data: FlowNodeData) => string;
+};
+
+const NODE_CONFIG: Record = {
+ condition: {
+ label: "Condition",
+ bar: "border-l-warning",
+ tone: "text-warning",
+ icon: ConditionIcon,
+ subtitle: (data) => {
+ const field = data.field === "tags" ? "Tags" : "Message";
+ const operator = data.operator === "not_includes" ? "does not include" : "includes";
+ const value = readString(data.value);
+ return value ? `${field} ${operator} "${value}"` : "Set the condition";
+ },
+ },
+ send_dm: {
+ label: "Send DM",
+ bar: "border-l-accent",
+ tone: "text-accent",
+ icon: SendDmIcon,
+ subtitle: (data) => readString(data.message) || "Write the message",
+ },
+ add_tag: {
+ label: "Add tag",
+ bar: "border-l-success",
+ tone: "text-success",
+ icon: AddTagIcon,
+ subtitle: (data) => readString(data.tag) || "Name the tag",
+ },
+ ai_reply: {
+ label: "AI reply",
+ bar: "border-l-foreground",
+ tone: "text-foreground",
+ icon: AiReplyIcon,
+ subtitle: (data) => readString(data.systemPrompt) || "Write the system prompt",
+ },
+};
+
+function readString(value: unknown): string {
+ return typeof value === "string" ? value.trim() : "";
+}
+
+function FlowNodeCard({ data, type, selected }: NodeProps) {
+ const config = NODE_CONFIG[type];
+ const NodeIcon = config.icon;
+ const isCondition = type === "condition";
+
+ return (
+
+
+
+
+
+ {config.label}
+
+
+
+ {config.subtitle(data)}
+
+
+ {isCondition ? (
+ <>
+
+ Yes
+ No
+
+
+
+ >
+ ) : (
+
+ )}
+
+ );
+}
+
+export default memo(FlowNodeCard);
diff --git a/components/follower-chart.tsx b/components/follower-chart.tsx
index 8d4af5f1..95e20885 100644
--- a/components/follower-chart.tsx
+++ b/components/follower-chart.tsx
@@ -31,7 +31,7 @@ export interface FollowerChartPoint {
// Colors read against the light chart surface (#ffffff): the accent line clears
// 3:1 contrast and grid/axis text match the muted/border tokens. See globals.css.
-const SERIES_COLOR = "#f97316";
+const SERIES_COLOR = "#0c3bb9";
const GRID_COLOR = "#e4e4e7";
const AXIS_TEXT = "#71717a";
diff --git a/components/sidebar.tsx b/components/sidebar.tsx
index bf7129b4..24fb2787 100644
--- a/components/sidebar.tsx
+++ b/components/sidebar.tsx
@@ -8,12 +8,14 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
+import { BrandMark } from "@/components/brand-mark";
const navItems = [
{ label: "Dashboard", href: "/dashboard" },
{ label: "Overview", href: "/overview" },
{ label: "Inbox", href: "/inbox" },
{ label: "Campaigns", href: "/campaigns" },
+ { label: "Contacts", href: "/contacts" },
{ label: "DM Logs", href: "/logs" },
{ label: "Settings", href: "/settings" },
{ label: "Diagnostics", href: "/diagnostics" },
@@ -44,7 +46,7 @@ export default function Sidebar({
@@ -75,8 +77,8 @@ export default function Sidebar({
block px-3 py-2.5 rounded text-sm
${
isActive
- ? "bg-surface-hover text-foreground font-medium"
- : "text-muted hover:text-foreground hover:bg-surface-hover"
+ ? "bg-white/10 text-white font-medium"
+ : "text-white/60 hover:text-white hover:bg-white/5"
}
`}
>
@@ -86,9 +88,9 @@ export default function Sidebar({
})}
-