"use client"; /** * Linear-list flow editor. * * The whole flow (header, trigger config, node list, validation panel) * is owned by this single component. State lives client-side as a * single `BuilderState` object; `Save` PUTs the whole structure to * `/api/flows/[id]`; `Activate` hits `/api/flows/[id]/activate`. * * Why one big file: keeps the diff between fields + the form code * obvious, matches the existing `automation-builder.tsx` shape, and * sidesteps over-componentization for a UI that will be replaced by a * react-flow canvas in v2 anyway. The node-config sub-forms live in * the same file as small components rather than separate modules. */ import { useCallback, useEffect, useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import { ArrowLeft, CircleCheck, CircleAlert, History, Loader2, Plus, Save, Trash2, Workflow, ChevronDown, ChevronUp, MessageCircle, ListChecks, ListPlus, CornerDownRight, UserPlus, Flag, PlayCircle, PauseCircle, Inbox, GitFork, Tag, } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Badge } from "@/components/ui/badge"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; import { validateFlowForActivation, type ValidationIssue, } from "@/lib/flows/validate"; import type { FlowNodeRow, FlowRow } from "@/lib/flows/types"; interface FlowBuilderProps { initialFlow: FlowRow; initialNodes: FlowNodeRow[]; } // ============================================================ // Local state shape — mirrors the DB but the configs are typed // loosely (Record) since each node_type carries a // different shape. The sub-form components narrow as needed. // ============================================================ type NodeType = | "start" | "send_message" | "send_buttons" | "send_list" | "collect_input" | "condition" | "set_tag" | "handoff" | "end"; interface BuilderNode { node_key: string; node_type: NodeType; config: Record; } interface BuilderState { name: string; description: string; trigger_type: "keyword" | "first_inbound_message" | "manual"; trigger_config: Record; entry_node_id: string | null; status: FlowRow["status"]; nodes: BuilderNode[]; } // ============================================================ // Per-node-type metadata used to render icons + labels everywhere // the user sees a node summary. // ============================================================ const NODE_META: Record< NodeType, { label: string; icon: typeof Workflow; color: string } > = { start: { label: "Start", icon: PlayCircle, color: "text-emerald-400" }, send_message: { label: "Send message", icon: MessageCircle, color: "text-sky-400", }, send_buttons: { label: "Send buttons", icon: ListChecks, color: "text-violet-400", }, send_list: { label: "Send list", icon: ListPlus, color: "text-indigo-400", }, collect_input: { label: "Collect input", icon: Inbox, color: "text-teal-400", }, condition: { label: "If / else", icon: GitFork, color: "text-fuchsia-400", }, set_tag: { label: "Tag contact", icon: Tag, color: "text-pink-400", }, handoff: { label: "Handoff to agent", icon: UserPlus, color: "text-amber-400", }, end: { label: "End", icon: Flag, color: "text-slate-400" }, }; // ============================================================ // Helpers // ============================================================ function slugify(s: string, fallback: string): string { const cleaned = s .toLowerCase() .trim() .replace(/[^a-z0-9]+/g, "_") .replace(/^_+|_+$/g, ""); return cleaned || fallback; } function uniqueNodeKey(base: string, existing: BuilderNode[]): string { if (!existing.some((n) => n.node_key === base)) return base; let i = 2; while (existing.some((n) => n.node_key === `${base}_${i}`)) i += 1; return `${base}_${i}`; } function defaultConfigFor(type: NodeType): Record { switch (type) { case "start": return { next_node_key: "" }; case "send_message": return { text: "", next_node_key: "" }; case "send_buttons": return { text: "", buttons: [{ reply_id: "yes", title: "Yes", next_node_key: "" }], }; case "send_list": return { text: "", button_label: "View options", sections: [ { title: "", rows: [ { reply_id: "row_1", title: "Option 1", next_node_key: "" }, ], }, ], }; case "collect_input": return { prompt_text: "", var_key: "answer", next_node_key: "", }; case "condition": return { subject: "var", subject_key: "", operator: "equals", value: "", true_next: "", false_next: "", }; case "set_tag": return { mode: "add", tag_id: "", next_node_key: "" }; case "handoff": return { note: "" }; case "end": return {}; } } // ============================================================ // Root component // ============================================================ export function FlowBuilder({ initialFlow, initialNodes }: FlowBuilderProps) { const router = useRouter(); const [state, setState] = useState(() => ({ name: initialFlow.name, description: initialFlow.description ?? "", trigger_type: initialFlow.trigger_type, trigger_config: initialFlow.trigger_config as Record, entry_node_id: initialFlow.entry_node_id, status: initialFlow.status, nodes: initialNodes.map((n) => ({ node_key: n.node_key, node_type: n.node_type as NodeType, config: n.config as Record, })), })); const [saving, setSaving] = useState(false); const [activating, setActivating] = useState(false); const [expanded, setExpanded] = useState>( () => new Set(initialNodes.map((n) => n.node_key)), ); // ---- Validation ---- const issues = useMemo( () => validateFlowForActivation( { name: state.name, trigger_type: state.trigger_type, trigger_config: state.trigger_config, entry_node_id: state.entry_node_id, }, state.nodes, ), [state], ); const blockers = issues.filter((i) => i.severity === "error"); const canActivate = blockers.length === 0; // ---- Save (PUT) ---- const handleSave = useCallback(async () => { setSaving(true); try { const res = await fetch(`/api/flows/${initialFlow.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: state.name, description: state.description || null, trigger_type: state.trigger_type, trigger_config: state.trigger_config, entry_node_id: state.entry_node_id, nodes: state.nodes, }), }); if (!res.ok) { const json = await res.json().catch(() => ({})); throw new Error(json.error ?? `Save failed: ${res.status}`); } toast.success("Saved."); } catch (err) { const msg = err instanceof Error ? err.message : "Save failed"; toast.error(msg); } finally { setSaving(false); } }, [initialFlow.id, state]); // ---- Activate / Pause / Archive ---- const handleStatus = useCallback( async (next: BuilderState["status"]) => { if (next === "active" && !canActivate) { toast.error("Fix the issues below before activating."); return; } setActivating(true); try { // Always save first so the activation validator sees the // latest state — the user shouldn't have to remember "save // then activate". if (next === "active") { await handleSave(); } const res = await fetch(`/api/flows/${initialFlow.id}/activate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: next }), }); if (!res.ok) { const json = await res.json().catch(() => ({})); throw new Error(json.error ?? `Status update failed: ${res.status}`); } setState((s) => ({ ...s, status: next })); toast.success( next === "active" ? "Flow activated." : next === "archived" ? "Archived." : "Saved as draft.", ); } catch (err) { const msg = err instanceof Error ? err.message : "Status update failed"; toast.error(msg); } finally { setActivating(false); } }, [canActivate, handleSave, initialFlow.id], ); // ---- Delete ---- const handleDelete = useCallback(async () => { const yes = window.confirm( `Delete "${state.name}"? Any active runs end immediately. This can't be undone.`, ); if (!yes) return; try { const res = await fetch(`/api/flows/${initialFlow.id}`, { method: "DELETE", }); if (!res.ok) throw new Error(`Delete failed: ${res.status}`); router.push("/flows"); } catch (err) { const msg = err instanceof Error ? err.message : "Delete failed"; toast.error(msg); } }, [initialFlow.id, router, state.name]); // ---- Node helpers ---- const updateNode = useCallback( (key: string, patch: Partial) => { setState((s) => ({ ...s, nodes: s.nodes.map((n) => (n.node_key === key ? { ...n, ...patch } : n)), })); }, [], ); const updateNodeConfig = useCallback( (key: string, configPatch: Record) => { setState((s) => ({ ...s, nodes: s.nodes.map((n) => n.node_key === key ? { ...n, config: { ...n.config, ...configPatch } } : n, ), })); }, [], ); const addNode = useCallback( (type: NodeType) => { const meta = NODE_META[type]; const base = slugify(meta.label, type); setState((s) => { const node_key = uniqueNodeKey(base, s.nodes); const next: BuilderNode = { node_key, node_type: type, config: defaultConfigFor(type), }; setExpanded((prev) => new Set([...prev, node_key])); return { ...s, nodes: [...s.nodes, next], // If this is the first node and it's a start, pick it as // the entry automatically. Saves a click. entry_node_id: s.entry_node_id ?? (type === "start" ? node_key : s.entry_node_id ?? null), }; }); }, [], ); const removeNode = useCallback((key: string) => { setState((s) => ({ ...s, nodes: s.nodes.filter((n) => n.node_key !== key), entry_node_id: s.entry_node_id === key ? null : s.entry_node_id, })); setExpanded((prev) => { const next = new Set(prev); next.delete(key); return next; }); }, []); const toggleExpanded = useCallback((key: string) => { setExpanded((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); }, []); // ---- Render ---- return (
router.push("/flows")} onViewRuns={() => router.push(`/flows/${initialFlow.id}/runs`)} /> i.scope === "trigger")} />

Nodes ({state.nodes.length})

{state.nodes.length === 0 ? (
Add a Start node, then a Send buttons {" "}node, then a Handoff — that's the welcome-menu shape from the brief.
) : ( state.nodes.map((node) => ( i.scope === "node" && i.node_key === node.node_key, )} onToggle={() => toggleExpanded(node.node_key)} onUpdate={(patch) => updateNode(node.node_key, patch)} onUpdateConfig={(patch) => updateNodeConfig(node.node_key, patch)} onRemove={() => removeNode(node.node_key)} onSetEntry={() => setState((s) => ({ ...s, entry_node_id: node.node_key })) } /> )) )}
); } // ============================================================ // Header // ============================================================ function Header({ state, setState, saving, activating, onSave, onStatus, onDelete, canActivate, onBack, onViewRuns, }: { state: BuilderState; setState: React.Dispatch>; saving: boolean; activating: boolean; onSave: () => void; onStatus: (s: BuilderState["status"]) => void; onDelete: () => void; canActivate: boolean; onBack: () => void; onViewRuns: () => void; }) { return (
setState((s) => ({ ...s, name: e.target.value })) } placeholder="Flow name" className="max-w-md bg-slate-900 text-lg font-semibold" />
{state.status === "active" ? ( ) : ( )}
setState((s) => ({ ...s, description: e.target.value })) } placeholder="Optional description (internal — customers don't see this)" className="bg-slate-900 text-sm" />
); } function StatusBadge({ status }: { status: BuilderState["status"] }) { const cls = { draft: "border-slate-700 bg-slate-800 text-slate-300", active: "border-emerald-600/40 bg-emerald-500/10 text-emerald-300", archived: "border-slate-700 bg-slate-800/50 text-slate-500", }[status]; return ( {status.charAt(0).toUpperCase() + status.slice(1)} ); } // ============================================================ // Trigger panel // ============================================================ function TriggerPanel({ state, setState, triggerIssues, }: { state: BuilderState; setState: React.Dispatch>; triggerIssues: ValidationIssue[]; }) { return (

Trigger

{state.trigger_type === "keyword" && (
setState((s) => ({ ...s, trigger_config: { ...s.trigger_config, keywords: e.target.value .split(",") .map((k) => k.trim()) .filter(Boolean), }, })) } placeholder="support, help, hi" className="bg-slate-800" />
)}
{triggerIssues.length > 0 && (
{triggerIssues.map((i, ix) => ( ))}
)}
); } // ============================================================ // Entry-node picker // ============================================================ function EntryPicker({ state, setState, }: { state: BuilderState; setState: React.Dispatch>; }) { if (state.nodes.length === 0) return null; return (
Entry node: setState((s) => ({ ...s, entry_node_id: key })) } placeholder="Pick the first node…" className="flex-1 max-w-xs" />
); } // ============================================================ // Node card — collapsed summary + expanded config form // ============================================================ function NodeCard({ node, allNodes, expanded, isEntry, issues, onToggle, onUpdate, onUpdateConfig, onRemove, onSetEntry, }: { node: BuilderNode; allNodes: BuilderNode[]; expanded: boolean; isEntry: boolean; issues: ValidationIssue[]; onToggle: () => void; onUpdate: (patch: Partial) => void; onUpdateConfig: (patch: Record) => void; onRemove: () => void; onSetEntry: () => void; }) { const meta = NODE_META[node.node_type]; const hasError = issues.some((i) => i.severity === "error"); return (
{expanded && (
{!isEntry && ( )}
{issues.length > 0 && (
{issues.map((i, ix) => ( ))}
)}
)}
); } // ============================================================ // Per-node-type config form // ============================================================ function NodeConfigForm({ node, allNodes, onUpdate, onUpdateConfig, }: { node: BuilderNode; allNodes: BuilderNode[]; onUpdate: (patch: Partial) => void; onUpdateConfig: (patch: Record) => void; }) { const cfg = node.config; return (
onUpdate({ node_key: slugify(e.target.value, node.node_key) }) } className="bg-slate-800" />
{node.node_type === "start" && ( onUpdateConfig({ next_node_key: v })} label="Advances to" /> )} {node.node_type === "send_message" && ( <> onUpdateConfig({ text: v })} /> onUpdateConfig({ next_node_key: v })} label="Advances to" /> )} {node.node_type === "send_buttons" && ( )} {node.node_type === "send_list" && ( )} {node.node_type === "collect_input" && ( <> onUpdateConfig({ prompt_text: v })} rows={2} />
onUpdateConfig({ var_key: e.target.value.replace(/[^a-zA-Z0-9_]/g, ""), }) } placeholder="e.g. name, email, company" className="bg-slate-800 font-mono text-xs" />

Interpolate in downstream prompts and handoff notes with{" "} {"{{vars."} {(cfg as { var_key?: string }).var_key || "name"} {"}}"} .

onUpdateConfig({ next_node_key: v })} label="After capturing, advance to" /> )} {node.node_type === "condition" && ( )} {node.node_type === "set_tag" && ( )} {node.node_type === "handoff" && ( onUpdateConfig({ note: v })} rows={2} /> )} {node.node_type === "end" && (

Terminal node. When the runner reaches this node the run is marked complete. No config needed.

)}
); } // ---- send_buttons form ---- interface SendButtonsCfg { text?: string; footer_text?: string; buttons?: Array<{ reply_id: string; title: string; next_node_key: string }>; } function SendButtonsForm({ cfg, allNodes, currentKey, onUpdateConfig, }: { cfg: SendButtonsCfg; allNodes: BuilderNode[]; currentKey: string; onUpdateConfig: (patch: Record) => void; }) { const buttons = cfg.buttons ?? []; const updateButton = ( idx: number, patch: Partial[number]>, ) => { onUpdateConfig({ buttons: buttons.map((b, i) => (i === idx ? { ...b, ...patch } : b)), }); }; const addButton = () => onUpdateConfig({ buttons: [ ...buttons, { reply_id: `btn_${buttons.length + 1}`, title: "Option", next_node_key: "", }, ], }); const removeButton = (idx: number) => onUpdateConfig({ buttons: buttons.filter((_, i) => i !== idx) }); return ( <> onUpdateConfig({ text: v })} rows={3} /> onUpdateConfig({ footer_text: v })} />
{buttons.map((b, i) => (
updateButton(i, { reply_id: slugify(e.target.value, `btn_${i + 1}`), }) } placeholder="reply_id" className="bg-slate-800 font-mono text-xs" /> updateButton(i, { title: e.target.value })} placeholder="Visible title (≤20 chars)" className="bg-slate-800" maxLength={20} /> updateButton(i, { next_node_key: v ?? "" })} placeholder="Next node…" />
))}
{buttons.length < 3 && ( )}
); } // ---- send_list form ---- interface SendListCfg { text?: string; button_label?: string; footer_text?: string; sections?: Array<{ title?: string; rows: Array<{ reply_id: string; title: string; description?: string; next_node_key: string; }>; }>; } function SendListForm({ cfg, allNodes, currentKey, onUpdateConfig, }: { cfg: SendListCfg; allNodes: BuilderNode[]; currentKey: string; onUpdateConfig: (patch: Record) => void; }) { const sections = cfg.sections ?? []; const totalRows = sections.reduce((sum, s) => sum + s.rows.length, 0); const updateSection = ( sIdx: number, patch: Partial[number]>, ) => { onUpdateConfig({ sections: sections.map((s, i) => i === sIdx ? { ...s, ...patch } : s, ), }); }; const updateRow = ( sIdx: number, rIdx: number, patch: Partial< NonNullable[number]["rows"][number] >, ) => { onUpdateConfig({ sections: sections.map((s, i) => i === sIdx ? { ...s, rows: s.rows.map((r, j) => (j === rIdx ? { ...r, ...patch } : r)), } : s, ), }); }; const addRow = (sIdx: number) => onUpdateConfig({ sections: sections.map((s, i) => i === sIdx ? { ...s, rows: [ ...s.rows, { reply_id: `row_${totalRows + 1}`, title: `Option ${totalRows + 1}`, next_node_key: "", }, ], } : s, ), }); const removeRow = (sIdx: number, rIdx: number) => onUpdateConfig({ sections: sections.map((s, i) => i === sIdx ? { ...s, rows: s.rows.filter((_, j) => j !== rIdx) } : s, ), }); return ( <> onUpdateConfig({ text: v })} rows={3} />
onUpdateConfig({ button_label: v })} /> onUpdateConfig({ footer_text: v })} />
{sections.map((section, sIdx) => (
updateSection(sIdx, { title: e.target.value }) } placeholder="Section title (optional)" className="mb-2 bg-slate-800 text-xs" /> {section.rows.map((row, rIdx) => (
updateRow(sIdx, rIdx, { reply_id: slugify( e.target.value, `row_${rIdx + 1}`, ), }) } placeholder="reply_id" className="bg-slate-800 font-mono text-xs" /> updateRow(sIdx, rIdx, { title: e.target.value }) } placeholder="Row title (≤24)" className="bg-slate-800" maxLength={24} /> updateRow(sIdx, rIdx, { next_node_key: v ?? "" }) } placeholder="Next node…" />
))} {totalRows < 10 && ( )}
))}
); } // ---- condition form ---- interface ConditionCfg { subject?: "var" | "tag" | "contact_field"; subject_key?: string; operator?: "equals" | "contains" | "present" | "absent"; value?: string; true_next?: string; false_next?: string; } interface UserTag { id: string; name: string; color?: string; } function ConditionForm({ cfg, allNodes, currentKey, onUpdateConfig, }: { cfg: ConditionCfg; allNodes: BuilderNode[]; currentKey: string; onUpdateConfig: (patch: Record) => void; }) { const [tags, setTags] = useState([]); useEffect(() => { let cancelled = false; (async () => { try { const res = await fetch("/api/tags").catch(() => null); if (!res || !res.ok) return; const json = (await res.json()) as { tags?: UserTag[] }; if (!cancelled) setTags(json.tags ?? []); } catch { // Tags endpoint absent on older deployments — fall back to a // plain text input so the condition is still authorable. } })(); return () => { cancelled = true; }; }, []); const subject = cfg.subject ?? "var"; const operator = cfg.operator ?? "equals"; const showValue = operator === "equals" || operator === "contains"; return ( <>
{subject === "tag" && tags.length > 0 ? ( ) : subject === "contact_field" ? ( ) : ( onUpdateConfig({ subject_key: e.target.value })} placeholder={subject === "var" ? "e.g. email" : "tag UUID"} className="bg-slate-800 font-mono text-xs" /> )}
{showValue && (
onUpdateConfig({ value: e.target.value })} className="bg-slate-800" />
)}
onUpdateConfig({ true_next: v })} label="If true → advance to" /> onUpdateConfig({ false_next: v })} label="If false → advance to" />
); } // ---- set_tag form ---- interface SetTagCfg { mode?: "add" | "remove"; tag_id?: string; next_node_key?: string; } function SetTagForm({ cfg, allNodes, currentKey, onUpdateConfig, }: { cfg: SetTagCfg; allNodes: BuilderNode[]; currentKey: string; onUpdateConfig: (patch: Record) => void; }) { const [tags, setTags] = useState([]); useEffect(() => { let cancelled = false; (async () => { try { const res = await fetch("/api/tags").catch(() => null); if (!res || !res.ok) return; const json = (await res.json()) as { tags?: UserTag[] }; if (!cancelled) setTags(json.tags ?? []); } catch { // No tags endpoint — fall back to raw UUID input. } })(); return () => { cancelled = true; }; }, []); return ( <>
{tags.length > 0 ? ( ) : ( onUpdateConfig({ tag_id: e.target.value })} placeholder="Tag UUID" className="bg-slate-800 font-mono text-xs" /> )}
onUpdateConfig({ next_node_key: v })} label="Then advance to" /> ); } // ---- Smaller field components ---- function TextRow({ label, value, onChange, rows = 1, }: { label: string; value: string; onChange: (v: string) => void; rows?: number; }) { return (
{rows > 1 ? (