"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, useRef, 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}`; } // Short, single-line content summary used in the collapsed NodeCard // header — lets users scan a 10-node flow without expanding every card. // Returns null when there's nothing meaningful to show (start/end, or // a freshly-added node with no fields filled in). function truncate(s: string, max = 80): string { const clean = s.replace(/\s+/g, " ").trim(); if (clean.length <= max) return clean; return clean.slice(0, max - 1) + "…"; } function summarizeNode(node: BuilderNode): string | null { const cfg = node.config; switch (node.node_type) { case "start": case "end": return null; case "send_message": { const text = typeof cfg.text === "string" ? cfg.text : ""; return text.length > 0 ? truncate(text) : null; } case "send_buttons": { const text = typeof cfg.text === "string" ? cfg.text : ""; const buttons = Array.isArray(cfg.buttons) ? (cfg.buttons as Array>) : []; const titles = buttons .map((b) => (typeof b.title === "string" ? b.title : "")) .filter(Boolean) .join(" / "); if (text.length > 0) { return titles ? `${truncate(text, 40)} · ${truncate(titles, 35)}` : truncate(text); } return titles || null; } case "send_list": { const text = typeof cfg.text === "string" ? cfg.text : ""; const sections = Array.isArray(cfg.sections) ? (cfg.sections as Array>) : []; const rowCount = sections.reduce((sum, s) => { const rows = Array.isArray(s.rows) ? s.rows : []; return sum + rows.length; }, 0); if (text.length > 0) { return rowCount > 0 ? `${truncate(text, 50)} · ${rowCount} option${rowCount === 1 ? "" : "s"}` : truncate(text); } return rowCount > 0 ? `${rowCount} option${rowCount === 1 ? "" : "s"} across ${sections.length} section${sections.length === 1 ? "" : "s"}` : null; } case "collect_input": { const prompt = typeof cfg.prompt_text === "string" ? cfg.prompt_text : ""; const varKey = typeof cfg.var_key === "string" ? cfg.var_key : ""; if (prompt.length > 0) { return varKey ? `${truncate(prompt, 50)} → vars.${varKey}` : truncate(prompt); } return varKey ? `→ vars.${varKey}` : null; } case "condition": { const subjectKey = typeof cfg.subject_key === "string" ? cfg.subject_key : ""; if (!subjectKey) return null; const subject = cfg.subject === "tag" ? "tag" : cfg.subject === "contact_field" ? "field" : "var"; const subjectStr = subject === "tag" ? `has tag ${truncate(subjectKey, 24)}` : `${subject}.${subjectKey}`; const op = cfg.operator === "equals" ? "==" : cfg.operator === "contains" ? "contains" : cfg.operator === "present" ? "exists" : cfg.operator === "absent" ? "missing" : ""; const value = typeof cfg.value === "string" ? cfg.value : ""; const valStr = (cfg.operator === "equals" || cfg.operator === "contains") && value ? ` "${truncate(value, 20)}"` : ""; return subject === "tag" ? subjectStr : `${subjectStr} ${op}${valStr}`; } case "set_tag": { const mode = cfg.mode === "remove" ? "Remove" : "Add"; const tagId = typeof cfg.tag_id === "string" ? cfg.tag_id : ""; // No tag name available without an async lookup here; show a // short prefix of the UUID so users can disambiguate between // multiple set_tag nodes at a glance. return tagId ? `${mode} tag ${tagId.slice(0, 8)}…` : `${mode} tag (none picked)`; } case "handoff": { const note = typeof cfg.note === "string" ? cfg.note : ""; return note.length > 0 ? truncate(note) : null; } } } 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)), ); // Tracks whether the in-memory state has user edits that haven't been // PUT yet. We use a wrapper setState (`setStateDirty`) for user edits; // status-only changes after the activate API succeeds use raw setState // so they don't falsely re-flag the form as dirty. const [dirty, setDirty] = useState(false); const setStateDirty = useCallback((updaterOrValue) => { setDirty(true); setState(updaterOrValue); }, []); // Used by jumpToNode() to scroll the target into view + flash its border. const nodeRefs = useRef>(new Map()); const [flashedKey, setFlashedKey] = useState(null); // Browser-level reload / tab-close / external-link guard. SPA // navigation (sidebar links, back button) isn't covered here — Next 16 // routes through the App Router and beforeunload doesn't fire on // client-side route changes. That's a follow-up; this catches the // accidental refresh / closed-window class of data loss. useEffect(() => { if (!dirty) return; const handler = (e: BeforeUnloadEvent) => { e.preventDefault(); // Modern browsers ignore the return value but require something // truthy to actually show the native prompt. e.returnValue = ""; }; window.addEventListener("beforeunload", handler); return () => window.removeEventListener("beforeunload", handler); }, [dirty]); // ---- 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}`); } setDirty(false); 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) => { setStateDirty((s) => ({ ...s, nodes: s.nodes.map((n) => (n.node_key === key ? { ...n, ...patch } : n)), })); }, [setStateDirty], ); const updateNodeConfig = useCallback( (key: string, configPatch: Record) => { setStateDirty((s) => ({ ...s, nodes: s.nodes.map((n) => n.node_key === key ? { ...n, config: { ...n.config, ...configPatch } } : n, ), })); }, [setStateDirty], ); const addNode = useCallback( (type: NodeType) => { const meta = NODE_META[type]; const base = slugify(meta.label, type); setStateDirty((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), }; }); }, [setStateDirty], ); const removeNode = useCallback( (key: string) => { setStateDirty((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; }); }, [setStateDirty], ); 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; }); }, []); // Jump-to-node: invoked when a user clicks an issue in the validation // panel. Expand the offending card (so the broken field is visible), // scroll it into the viewport, then flash its border so the eye lands // on it. requestAnimationFrame defers the scroll until after React // commits the expanded layout. const jumpToNode = useCallback((key: string) => { setExpanded((prev) => { if (prev.has(key)) return prev; const next = new Set(prev); next.add(key); return next; }); setFlashedKey(key); requestAnimationFrame(() => { const el = nodeRefs.current.get(key); el?.scrollIntoView({ behavior: "smooth", block: "center" }); }); window.setTimeout(() => { setFlashedKey((cur) => (cur === key ? null : cur)); }, 1600); }, []); const setNodeRef = useCallback( (key: string) => (el: HTMLDivElement | null) => { if (el) nodeRefs.current.set(key, el); else nodeRefs.current.delete(key); }, [], ); // ---- 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={() => setStateDirty((s) => ({ ...s, entry_node_id: node.node_key })) } /> )) )}
{/* Sticky-bottom so the activate-readiness status follows the user as they scroll through nodes. The parent
in the dashboard shell is the scroll container; this stays pinned to the viewport bottom (with a 1rem gap) until the page naturally ends, at which point it falls back into flow. */}
); } // ============================================================ // Header // ============================================================ function Header({ state, setState, dirty, saving, activating, onSave, onStatus, onDelete, canActivate, onBack, onViewRuns, }: { state: BuilderState; setState: React.Dispatch>; dirty: boolean; 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" /> {dirty && ( Edited )}
{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, isFlashed, cardRef, issues, onToggle, onUpdate, onUpdateConfig, onRemove, onSetEntry, }: { node: BuilderNode; allNodes: BuilderNode[]; expanded: boolean; isEntry: boolean; isFlashed: boolean; cardRef: (el: HTMLDivElement | null) => void; 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"); const preview = summarizeNode(node); 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; // Internal identifiers (node_key, reply_id columns on buttons/list rows) // are auto-generated and the runner is the only consumer. Hide them by // default so the form reads as plain editing; expose under "Advanced" // for the rare case where someone wants to lock a key for stable // analytics or external integration. const [showAdvanced, setShowAdvanced] = useState(false); const hasReplyIds = node.node_type === "send_buttons" || node.node_type === "send_list"; return (
{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.

)}
{showAdvanced && (
onUpdate({ node_key: slugify(e.target.value, node.node_key) }) } className="bg-slate-800 font-mono text-xs" />
{hasReplyIds && (

Reply IDs for each option are shown inline above. They're returned by WhatsApp when a customer taps; you usually don't need to touch them.

)}
)}
); } // ---- 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, showAdvanced, }: { cfg: SendButtonsCfg; allNodes: BuilderNode[]; currentKey: string; onUpdateConfig: (patch: Record) => void; showAdvanced: boolean; }) { 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) => (
{showAdvanced && ( 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, showAdvanced, }: { cfg: SendListCfg; allNodes: BuilderNode[]; currentKey: string; onUpdateConfig: (patch: Record) => void; showAdvanced: boolean; }) { 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 addSection = () => onUpdateConfig({ sections: [ ...sections, { title: "", rows: [ { reply_id: `row_${totalRows + 1}`, title: `Option ${totalRows + 1}`, next_node_key: "", }, ], }, ], }); const removeSection = (sIdx: number) => onUpdateConfig({ sections: sections.filter((_, i) => i !== sIdx) }); 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 ${sIdx + 1} title (optional)`} className="bg-slate-800 text-xs" /> {sections.length > 1 && ( )}
{section.rows.map((row, rIdx) => (
{showAdvanced && ( 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 && ( )}
))} {/* WhatsApp's interactive-list spec caps sections at 10. Group rows by category (Billing / Support / Sales etc.) to give customers a scannable menu. */} {sections.length < 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 ? (