"use client" import { useState } from "react" import { useRouter } from "next/navigation" import { toast } from "sonner" import { ArrowLeft, ChevronDown, Plus, Trash2, GripVertical, MessageSquare, FileText, Tag, TagIcon, UserCheck, PencilLine, Briefcase, Hourglass, GitBranch, Webhook, CircleSlash, Zap, Loader2, ArrowDown, ArrowUp, } from "lucide-react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" import { Switch } from "@/components/ui/switch" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import type { AutomationStepType, AutomationTriggerType, KeywordMatchTriggerConfig, } from "@/types" import { cn } from "@/lib/utils" // ------------------------------------------------------------ // Types (builder-local — mirror the flattened rows we POST) // ------------------------------------------------------------ export interface BuilderStep { /** Client id; the API assigns real UUIDs server-side. */ cid: string step_type: AutomationStepType step_config: Record branches?: { yes: BuilderStep[]; no: BuilderStep[] } } export interface BuilderInitial { id?: string name: string description: string trigger_type: AutomationTriggerType trigger_config: Record is_active: boolean steps: BuilderStep[] } // ------------------------------------------------------------ // Step metadata — one source of truth for icon + label + border color // ------------------------------------------------------------ interface StepMeta { label: string icon: typeof Zap /** Left-border accent color per spec. */ border: string } const STEP_META: Record = { send_message: { label: "Send Message", icon: MessageSquare, border: "border-l-violet-500" }, send_template: { label: "Send Template", icon: FileText, border: "border-l-violet-500" }, add_tag: { label: "Add Tag", icon: Tag, border: "border-l-violet-500" }, remove_tag: { label: "Remove Tag", icon: TagIcon, border: "border-l-violet-500" }, assign_conversation: { label: "Assign Conversation", icon: UserCheck, border: "border-l-violet-500" }, update_contact_field: { label: "Update Contact Field", icon: PencilLine, border: "border-l-violet-500" }, create_deal: { label: "Create Deal", icon: Briefcase, border: "border-l-violet-500" }, wait: { label: "Wait", icon: Hourglass, border: "border-l-slate-500" }, condition: { label: "Condition (If/Else)", icon: GitBranch, border: "border-l-amber-500" }, send_webhook: { label: "Send Webhook", icon: Webhook, border: "border-l-violet-500" }, close_conversation: { label: "Close Conversation", icon: CircleSlash, border: "border-l-violet-500" }, } const ADDABLE_STEPS: AutomationStepType[] = [ "send_message", "send_template", "add_tag", "remove_tag", "assign_conversation", "update_contact_field", "create_deal", "wait", "condition", "send_webhook", "close_conversation", ] const TRIGGER_OPTIONS: { value: AutomationTriggerType; label: string; hint: string }[] = [ { value: "new_message_received", label: "New Message Received", hint: "Any incoming message" }, { value: "first_inbound_message", label: "First Message from Contact", hint: "First time this contact ever messages you (works for manually-added contacts too)", }, { value: "keyword_match", label: "Keyword Match", hint: "Message contains specific keyword(s)" }, { value: "new_contact_created", label: "New Contact Created", hint: "When a contact is auto-created from an incoming message" }, { value: "conversation_assigned", label: "Conversation Assigned", hint: "When assigned to an agent" }, { value: "tag_added", label: "Tag Added", hint: "When a tag is added to a contact" }, { value: "time_based", label: "Time-Based", hint: "On a recurring schedule" }, ] function cid(): string { return ( "c_" + (typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : Math.random().toString(36).slice(2) + Date.now().toString(36)) ) } function blankConfig(type: AutomationStepType): Record { switch (type) { case "send_message": return { text: "" } case "send_template": return { template_name: "", language: "en_US" } case "add_tag": case "remove_tag": return { tag_id: "" } case "assign_conversation": return { mode: "round_robin" } case "update_contact_field": return { field: "name", value: "" } case "create_deal": return { pipeline_id: "", stage_id: "", title: "", value: 0 } case "wait": return { amount: 1, unit: "hours" } case "condition": return { subject: "tag_presence", operand: "", value: "" } case "send_webhook": return { url: "", headers: {}, body_template: "" } case "close_conversation": return {} default: return {} } } // ------------------------------------------------------------ // Main builder component // ------------------------------------------------------------ export function AutomationBuilder({ initial }: { initial: BuilderInitial }) { const router = useRouter() const isEditing = !!initial.id const [state, setState] = useState(initial) const [saving, setSaving] = useState(false) const [expandedId, setExpandedId] = useState(null) function patchTop(key: K, value: BuilderInitial[K]) { setState((s) => ({ ...s, [key]: value })) } // --- Step tree mutations (immutable) --- function updateStep(path: StepPath, updater: (s: BuilderStep) => BuilderStep) { setState((s) => ({ ...s, steps: mapAtPath(s.steps, path, updater) })) } function addStepAt(parent: ParentScope, index: number, type: AutomationStepType) { const node: BuilderStep = { cid: cid(), step_type: type, step_config: blankConfig(type), branches: type === "condition" ? { yes: [], no: [] } : undefined, } setState((s) => ({ ...s, steps: insertAt(s.steps, parent, index, node) })) setExpandedId(node.cid) } function deleteStepAt(path: StepPath) { setState((s) => ({ ...s, steps: removeAt(s.steps, path) })) } function moveStepAt(path: StepPath, direction: -1 | 1) { setState((s) => ({ ...s, steps: moveAt(s.steps, path, direction) })) } async function save() { setSaving(true) try { const payload = { name: state.name || "Untitled automation", description: state.description || null, trigger_type: state.trigger_type, trigger_config: state.trigger_config, is_active: state.is_active, steps: toApiSteps(state.steps), } const res = isEditing ? await fetch(`/api/automations/${initial.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(payload), }) : await fetch(`/api/automations`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload), }) const body = await res.json().catch(() => ({})) if (!res.ok) { // If the server blocked activation with validation issues, // surface the first concrete problem so the user can fix it // without opening DevTools for the full array. const firstIssue: { path?: string; message?: string } | undefined = body?.issues?.[0] if (firstIssue?.message) { toast.error(firstIssue.message, { description: firstIssue.path ? `at ${firstIssue.path}` : undefined, }) } else { toast.error(body?.error ?? "Save failed") } return } toast.success(isEditing ? "Automation saved" : "Automation created") if (!isEditing && body?.automation?.id) { router.replace(`/automations/${body.automation.id}/edit`) } } finally { setSaving(false) } } return (
{/* Top bar. At sub-sm widths the "Active" label is hidden and the switch moves to the right of the save button, so the name input gets maximum width. */}
patchTop("name", e.target.value)} placeholder="Untitled automation" className="min-w-0 flex-1 rounded-md bg-transparent px-2 py-1 text-sm font-semibold text-white placeholder:text-slate-500 focus:bg-slate-800 focus:outline-none sm:text-base" />
Active patchTop("is_active", !!v)} aria-label="Active" />
{/* Canvas */}
patchTop("trigger_type", t)} onConfigChange={(c) => patchTop("trigger_config", c)} />
) } // ------------------------------------------------------------ // Trigger card // ------------------------------------------------------------ function TriggerCard({ type, config, onTypeChange, onConfigChange, }: { type: AutomationTriggerType config: Record onTypeChange: (t: AutomationTriggerType) => void onConfigChange: (c: Record) => void }) { const [open, setOpen] = useState(false) return ( // Card width: full on mobile, fixed 320px on sm+. The canvas wrapper // (max-w-2xl + px-4) keeps this tidy on tablet/desktop.
{open && (

{TRIGGER_OPTIONS.find((o) => o.value === type)?.hint}

{type === "keyword_match" && ( )} {type === "tag_added" && ( onConfigChange({ ...config, tag_id: e.target.value }) } className="bg-slate-800 text-white" /> )} {type === "time_based" && ( onConfigChange({ ...config, schedule: e.target.value }) } className="bg-slate-800 text-white" /> )}
)}
) } function KeywordMatchConfig({ config, onChange, }: { config: KeywordMatchTriggerConfig onChange: (c: Record) => void }) { const keywords = config?.keywords ?? [] return (
onChange({ ...config, keywords: e.target.value .split(",") .map((s) => s.trim()) .filter(Boolean), }) } className="bg-slate-800 text-white" />
) } // ------------------------------------------------------------ // Step list + card + connectors // ------------------------------------------------------------ type ParentScope = | { kind: "root" } | { kind: "branch"; parentCid: string; branch: "yes" | "no" } type StepPath = ( | { kind: "root"; index: number } | { kind: "branch"; parentCid: string; branch: "yes" | "no"; index: number } )[] interface StepListProps { steps: BuilderStep[] parentPath: StepPath expandedId: string | null setExpandedId: (id: string | null) => void updateStep: (path: StepPath, updater: (s: BuilderStep) => BuilderStep) => void addStepAt: (parent: ParentScope, index: number, type: AutomationStepType) => void deleteStepAt: (path: StepPath) => void moveStepAt: (path: StepPath, direction: -1 | 1) => void } function StepList(props: StepListProps) { const { steps, parentPath, ...rest } = props const parentScope: ParentScope = parentPath.length === 0 ? { kind: "root" } : (() => { const last = parentPath[parentPath.length - 1] if (last.kind !== "branch") return { kind: "root" } as const return { kind: "branch", parentCid: last.parentCid, branch: last.branch } as const })() return (
props.addStepAt(parentScope, 0, t)} /> {steps.map((step, idx) => ( ))}
) } function StepRenderer({ step, index, total, parentScope, parentPath, ...props }: { step: BuilderStep index: number total: number parentScope: ParentScope parentPath: StepPath } & Omit) { const path: StepPath = [ ...parentPath, parentScope.kind === "root" ? { kind: "root", index } : { kind: "branch", parentCid: parentScope.parentCid, branch: parentScope.branch, index }, ] const meta = STEP_META[step.step_type] const Icon = meta.icon const expanded = props.expandedId === step.cid const isCondition = step.step_type === "condition" // Card widths on mobile fill the full canvas column (max-w-2xl px-4 // still keeps them reasonable). On sm+ the original fixed widths // come back so the flow visual stays recognisable. const width = isCondition ? "w-full max-w-[400px] sm:w-[400px]" : "w-full max-w-[320px] sm:w-80" return ( <>
{expanded && (
props.updateStep(path, () => next)} />
)}
{isCondition && ( )}
props.addStepAt(parentScope, index + 1, t)} /> ) } function ConditionBranches({ step, parentPath, ...props }: { step: BuilderStep parentPath: StepPath } & Omit) { const yes = step.branches?.yes ?? [] const no = step.branches?.no ?? [] // Build the child scope by appending a branch marker. The scope the // StepList uses is driven by the LAST element of parentPath, so the // tail's `index` doesn't matter — it's replaced per child during walks. const yesPath: StepPath = [ ...parentPath, { kind: "branch", parentCid: step.cid, branch: "yes", index: 0 }, ] const noPath: StepPath = [ ...parentPath, { kind: "branch", parentCid: step.cid, branch: "no", index: 0 }, ] return ( // Stack Yes/No vertically on mobile — two columns at 375px would // cram each branch to ~170px which is too narrow for the nested // cards. Two-column grid returns on sm+.
) } function BranchColumn({ label, color, children, }: { label: string color: string children: React.ReactNode }) { return (
{label}
{children}
) } function AddButton({ onPick }: { onPick: (t: AutomationStepType) => void }) { return (
{ADDABLE_STEPS.map((t) => { const Icon = STEP_META[t].icon return ( onPick(t)}> {STEP_META[t].label} ) })}
) } // ------------------------------------------------------------ // Per-step config editor // ------------------------------------------------------------ function StepEditor({ step, onChange, }: { step: BuilderStep onChange: (s: BuilderStep) => void }) { const cfg = step.step_config const set = (patch: Record) => onChange({ ...step, step_config: { ...cfg, ...patch } }) switch (step.step_type) { case "send_message": return (