"use client" import { createContext, useContext, useEffect, useState, type ReactNode, } 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 { AccountMember, AutomationStepType, AutomationTriggerType, CustomField, KeywordMatchTriggerConfig, MessageTemplate, Tag as TagRecord, } from "@/types" import { createClient } from "@/lib/supabase/client" 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-primary" }, send_template: { label: "Send Template", icon: FileText, border: "border-l-primary" }, add_tag: { label: "Add Tag", icon: Tag, border: "border-l-primary" }, remove_tag: { label: "Remove Tag", icon: TagIcon, border: "border-l-primary" }, assign_conversation: { label: "Assign Conversation", icon: UserCheck, border: "border-l-primary" }, update_contact_field: { label: "Update Contact Field", icon: PencilLine, border: "border-l-primary" }, create_deal: { label: "Create Deal", icon: Briefcase, border: "border-l-primary" }, wait: { label: "Wait", icon: Hourglass, border: "border-l-border" }, condition: { label: "Condition (If/Else)", icon: GitBranch, border: "border-l-amber-500" }, send_webhook: { label: "Send Webhook", icon: Webhook, border: "border-l-primary" }, close_conversation: { label: "Close Conversation", icon: CircleSlash, border: "border-l-primary" }, } 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 {} } } // ------------------------------------------------------------ // Account resources (tags, members, approved templates) // // Loaded once at the builder root and shared via context so the // tag / agent / template pickers below can offer existing resources // by name instead of asking the user to paste raw UUIDs. Every picker // falls back to a raw input when its list is empty (fresh account or // an older deployment), so an automation is always authorable. // ------------------------------------------------------------ interface AutomationResources { tags: TagRecord[] members: AccountMember[] templates: MessageTemplate[] customFields: CustomField[] } const ResourcesContext = createContext({ tags: [], members: [], templates: [], customFields: [], }) function useResources(): AutomationResources { return useContext(ResourcesContext) } function ResourcesProvider({ children }: { children: ReactNode }) { const [tags, setTags] = useState([]) const [members, setMembers] = useState([]) const [templates, setTemplates] = useState([]) const [customFields, setCustomFields] = useState([]) useEffect(() => { let cancelled = false const supabase = createClient() // Tags, templates and custom fields come straight from the DB — RLS // scopes them to the caller's account. Only APPROVED templates can // actually be sent (anything else 400s at send time), matching the // broadcast picker. void (async () => { const [tagsRes, templatesRes, customFieldsRes] = await Promise.all([ supabase.from("tags").select("*").order("name"), supabase .from("message_templates") .select("*") .eq("status", "APPROVED") .order("name"), supabase.from("custom_fields").select("*").order("field_name"), ]) if (cancelled) return setTags((tagsRes.data as TagRecord[] | null) ?? []) setTemplates((templatesRes.data as MessageTemplate[] | null) ?? []) setCustomFields((customFieldsRes.data as CustomField[] | null) ?? []) })() // Members go through the API so we inherit its email-visibility // rules (agents/viewers don't see emails). Unreachable on older // deployments → pickers fall back to a raw agent-id input. void (async () => { try { const res = await fetch("/api/account/members", { cache: "no-store" }) if (!res.ok) return const json = (await res.json()) as { members?: AccountMember[] } if (!cancelled) setMembers(json.members ?? []) } catch { // Members endpoint absent — caller falls back to raw input. } })() return () => { cancelled = true } }, []) return ( {children} ) } const SELECT_CLASS = "w-full rounded-md border border-border bg-muted px-2 py-1.5 text-sm text-foreground focus:border-primary focus:outline-none" /** Tag dropdown by name + color, storing the tag's id. Falls back to a * raw id input when no tags exist yet. */ function TagSelect({ value, onChange, }: { value: string onChange: (v: string) => void }) { const { tags } = useResources() if (tags.length === 0) { return ( onChange(e.target.value)} className="bg-muted text-foreground" /> ) } const selected = tags.find((t) => t.id === value) return (
) } /** Contact-field dropdown for "Update Contact Field": built-in columns plus * any account custom fields (stored as `custom:`). A saved custom field * that's since been deleted is preserved as a labelled option so editing an * existing automation doesn't silently drop it. */ function ContactFieldSelect({ value, onChange, }: { value: string onChange: (v: string) => void }) { const { customFields } = useResources() const customValue = value.startsWith("custom:") ? value : "" const knownCustom = customValue && customFields.some((f) => `custom:${f.id}` === customValue) return ( ) } /** Agent dropdown by name, storing the member's user_id. Falls back to * a raw id input when the member list is unavailable. */ function AgentSelect({ value, onChange, }: { value: string onChange: (v: string) => void }) { const { members } = useResources() if (members.length === 0) { return ( onChange(e.target.value)} className="bg-muted text-foreground" /> ) } const selected = members.find((m) => m.user_id === value) return ( ) } /** Template dropdown showing approved templates by name + language, * storing both template_name and language. Falls back to manual name + * language inputs when no approved templates are synced yet. */ function SendTemplateFields({ templateName, language, onChange, }: { templateName: string language: string onChange: (patch: { template_name: string; language: string }) => void }) { const { templates } = useResources() if (templates.length === 0) { return ( <> onChange({ template_name: e.target.value, language }) } className="bg-muted text-foreground" /> onChange({ template_name: templateName, language: e.target.value }) } className="bg-muted text-foreground" /> ) } // Encode name + language in the option value so two templates that // share a name across languages stay distinct. const toValue = (name: string, lang: string) => `${name}::${lang}` const current = templateName ? toValue(templateName, language) : "" const hasMatch = templates.some( (t) => toValue(t.name, t.language ?? "en_US") === current, ) 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-foreground placeholder:text-muted-foreground focus:bg-muted 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: v })} />
)} {type === "time_based" && ( onConfigChange({ ...config, schedule: e.target.value }) } className="bg-muted text-foreground" /> )}
)}
) } function KeywordMatchConfig({ config, onChange, }: { config: KeywordMatchTriggerConfig onChange: (c: Record) => void }) { const keywords = config?.keywords ?? [] // Keep a local draft string so the comma and trailing space aren't // stripped on every keystroke (which made multi-word, comma-separated // entry like "SEO, search engine optimization" impossible to type). // We only parse into the keywords array on blur, then re-display the // cleaned, rejoined form. Seeded once on mount; this component remounts // when the trigger type changes, so the seed stays in sync. const [draft, setDraft] = useState(keywords.join(", ")) function commit() { const parsed = draft .split(",") .map((s) => s.trim()) .filter(Boolean) setDraft(parsed.join(", ")) onChange({ ...config, keywords: parsed }) } return (
setDraft(e.target.value)} onBlur={commit} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault() commit() } }} placeholder="e.g. pricing, demo request, talk to sales" className="bg-muted text-foreground" />
) } // ------------------------------------------------------------ // 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 (