wacrm/src/components/automations/automation-builder.tsx
Arnas Donauskas 78b42dfba9 feat: WhatsApp interactive messages (reply buttons + lists)
Add full support for WhatsApp Cloud API interactive messages across the
inbox, automations, and a new quick-replies feature. Extends the
interactive plumbing that previously existed only for the Flows engine.

Sending
- send-message core handles `interactive` (reply buttons ≤3, list ≤10
  rows) via the existing Meta senders; validated server-side before send.
- Inbox composer gains a "+" menu → builder dialog with live WhatsApp-style
  preview + Meta char limits (button 20, list row 24, body 1024).
- Automation "Send Buttons" / "Send List" steps in the builder.
- Public v1 /messages accepts type=interactive.

Storage / round-trip
- migration 035: messages.interactive_payload JSONB + quick_replies table.
- Outbound interactive persists the structured payload (composer,
  automations, and Flows) so the thread re-renders the buttons/rows.

Receiving
- Bubble renders outbound interactive via the shared preview; inbound taps
  (button_reply/list_reply) keep the "chosen option" affordance.

Automations
- New `interactive_reply` trigger matches on tapped button/list IDs
  (exact), enabling chained multi-step menus.

Quick replies
- New account-scoped quick_replies (text or interactive snippets), managed
  in Settings and insertable from the composer; "Save as quick reply" in
  the builder.

Shared core in src/lib/whatsapp/interactive.ts + src/components/interactive/.
Includes unit tests for the validator, send-param validation, the
interactive_reply trigger match, and the new automation step/trigger
validation. typecheck + lint clean; full suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:03:15 +03:00

1741 lines
56 KiB
TypeScript

"use client"
import {
createContext,
useContext,
useEffect,
useState,
type ReactNode,
} from "react"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
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,
MousePointerClick,
List,
} 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,
InteractiveMessagePayload,
KeywordMatchTriggerConfig,
MessageTemplate,
Tag as TagRecord,
} from "@/types"
import {
InteractiveBuilder,
blankButtonsPayload,
blankListPayload,
} from "@/components/interactive/interactive-builder"
import { interactivePayloadPreviewText } from "@/lib/whatsapp/interactive"
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<string, unknown>
branches?: { yes: BuilderStep[]; no: BuilderStep[] }
}
export interface BuilderInitial {
id?: string
name: string
description: string
trigger_type: AutomationTriggerType
trigger_config: Record<string, unknown>
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<AutomationStepType, StepMeta> = {
send_message: { label: "send_message", icon: MessageSquare, border: "border-l-primary" },
send_buttons: { label: "send_buttons", icon: MousePointerClick, border: "border-l-primary" },
send_list: { label: "send_list", icon: List, 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", 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_buttons",
"send_list",
"send_template",
"add_tag",
"remove_tag",
"assign_conversation",
"update_contact_field",
"create_deal",
"wait",
"condition",
"send_webhook",
"close_conversation",
]
const TRIGGER_OPTIONS: { value: AutomationTriggerType }[] = [
{ value: "new_message_received" },
{ value: "first_inbound_message" },
{ value: "keyword_match" },
{ value: "interactive_reply" },
{ value: "new_contact_created" },
{ value: "conversation_assigned" },
{ value: "tag_added" },
{ value: "time_based" },
]
function cid(): string {
return (
"c_" +
(typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: Math.random().toString(36).slice(2) + Date.now().toString(36))
)
}
// The send_buttons / send_list step_config IS an InteractiveMessagePayload,
// but step_config is typed generically as Record<string, unknown>. These two
// helpers hold the single unavoidable structural cast in one place so a
// payload-shape change has one seam to update instead of four scattered
// `as unknown as` sites.
function toStepConfig(p: InteractiveMessagePayload): Record<string, unknown> {
return p as unknown as Record<string, unknown>
}
function asInteractive(cfg: Record<string, unknown>): InteractiveMessagePayload {
return cfg as unknown as InteractiveMessagePayload
}
function blankConfig(type: AutomationStepType): Record<string, unknown> {
switch (type) {
case "send_message":
return { text: "" }
case "send_buttons":
return toStepConfig(blankButtonsPayload())
case "send_list":
return toStepConfig(blankListPayload())
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, pipelines)
//
// 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[]
pipelines: PipelineOption[]
stages: PipelineStageOption[]
}
interface PipelineOption {
id: string
name: string
}
interface PipelineStageOption {
id: string
name: string
pipeline_id: string
position: number
}
const ResourcesContext = createContext<AutomationResources>({
tags: [],
members: [],
templates: [],
customFields: [],
pipelines: [],
stages: [],
})
function useResources(): AutomationResources {
return useContext(ResourcesContext)
}
function ResourcesProvider({ children }: { children: ReactNode }) {
const [tags, setTags] = useState<TagRecord[]>([])
const [members, setMembers] = useState<AccountMember[]>([])
const [templates, setTemplates] = useState<MessageTemplate[]>([])
const [customFields, setCustomFields] = useState<CustomField[]>([])
const [pipelines, setPipelines] = useState<PipelineOption[]>([])
const [stages, setStages] = useState<PipelineStageOption[]>([])
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, pipelinesRes, stagesRes] =
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"),
supabase.from("pipelines").select("id, name").order("name"),
supabase
.from("pipeline_stages")
.select("id, name, pipeline_id, position")
.order("position"),
])
if (cancelled) return
setTags((tagsRes.data as TagRecord[] | null) ?? [])
setTemplates((templatesRes.data as MessageTemplate[] | null) ?? [])
setCustomFields((customFieldsRes.data as CustomField[] | null) ?? [])
setPipelines((pipelinesRes.data as PipelineOption[] | null) ?? [])
setStages((stagesRes.data as PipelineStageOption[] | 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 (
<ResourcesContext.Provider
value={{ tags, members, templates, customFields, pipelines, stages }}
>
{children}
</ResourcesContext.Provider>
)
}
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,
t,
}: {
value: string
onChange: (v: string) => void
t: ReturnType<typeof useTranslations>
}) {
const { tags } = useResources()
if (tags.length === 0) {
return (
<Input
placeholder={t("tags.placeholder")}
value={value}
onChange={(e) => onChange(e.target.value)}
className="bg-muted text-foreground"
/>
)
}
const selected = tags.find((t) => t.id === value)
return (
<div className="flex items-center gap-2">
<span
className="h-3 w-3 shrink-0 rounded-full border border-border"
style={{ backgroundColor: selected?.color ?? "transparent" }}
aria-hidden
/>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className={SELECT_CLASS}
>
<option value="">{t("tags.select")}</option>
{tags.map((tg) => (
<option key={tg.id} value={tg.id}>
{tg.name}
</option>
))}
{/* Preserve a saved tag that's since been deleted so editing an
existing automation doesn't silently drop it. */}
{value && !selected && (
<option value={value}>{t("tags.unknown", { id: value })}</option>
)}
</select>
</div>
)
}
/** Contact-field dropdown for "Update Contact Field": built-in columns plus
* any account custom fields (stored as `custom:<id>`). 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,
t,
}: {
value: string
onChange: (v: string) => void
t: ReturnType<typeof useTranslations>
}) {
const { customFields } = useResources()
const customValue = value.startsWith("custom:") ? value : ""
const knownCustom =
customValue && customFields.some((f) => `custom:${f.id}` === customValue)
return (
<select
value={value || "name"}
onChange={(e) => onChange(e.target.value)}
className={SELECT_CLASS}
>
<option value="name">{t("fields.name")}</option>
<option value="email">{t("fields.email")}</option>
<option value="company">{t("fields.company")}</option>
{customFields.length > 0 && (
<optgroup label={t("fields.customFields")}>
{customFields.map((f) => (
<option key={f.id} value={`custom:${f.id}`}>
{f.field_name}
</option>
))}
</optgroup>
)}
{customValue && !knownCustom && (
<option value={customValue}>{t("fields.unknown", { id: customValue })}</option>
)}
</select>
)
}
/** 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,
t,
}: {
value: string
onChange: (v: string) => void
t: ReturnType<typeof useTranslations>
}) {
const { members } = useResources()
if (members.length === 0) {
return (
<Input
placeholder={t("agents.placeholder")}
value={value}
onChange={(e) => onChange(e.target.value)}
className="bg-muted text-foreground"
/>
)
}
const selected = members.find((m) => m.user_id === value)
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className={SELECT_CLASS}
>
<option value="">{t("agents.select")}</option>
{members.map((m) => (
<option key={m.user_id} value={m.user_id}>
{m.full_name || m.email || m.user_id}
</option>
))}
{value && !selected && (
<option value={value}>{t("agents.unknown", { id: value })}</option>
)}
</select>
)
}
/** Pipeline + stage picker for Create Deal. The automation stores ids because
* the engine writes directly to deals, but authors should choose by name. */
function DealPipelineFields({
pipelineId,
stageId,
onChange,
t,
}: {
pipelineId: string
stageId: string
onChange: (patch: { pipeline_id: string; stage_id: string }) => void
t: ReturnType<typeof useTranslations>
}) {
const { pipelines, stages } = useResources()
if (pipelines.length === 0) {
return (
<>
<FieldBlock label={t("pipelines.pipelineIdLabel")}>
<Input
value={pipelineId}
onChange={(e) =>
onChange({ pipeline_id: e.target.value, stage_id: stageId })
}
className="bg-muted text-foreground"
/>
</FieldBlock>
<FieldBlock label={t("pipelines.stageIdLabel")}>
<Input
value={stageId}
onChange={(e) =>
onChange({ pipeline_id: pipelineId, stage_id: e.target.value })
}
className="bg-muted text-foreground"
/>
</FieldBlock>
</>
)
}
const selectedPipeline = pipelines.find((p) => p.id === pipelineId)
const stageOptions = stages.filter((s) => s.pipeline_id === pipelineId)
const selectedStage = stageOptions.find((s) => s.id === stageId)
return (
<>
<FieldBlock label={t("pipelines.pipelineLabel")}>
<select
value={pipelineId}
onChange={(e) => {
const nextPipelineId = e.target.value
const firstStage = stages.find(
(s) => s.pipeline_id === nextPipelineId
)
onChange({
pipeline_id: nextPipelineId,
stage_id: firstStage?.id ?? "",
})
}}
className={SELECT_CLASS}
>
<option value="">{t("pipelines.selectPipeline")}</option>
{pipelines.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
{pipelineId && !selectedPipeline && (
<option value={pipelineId}>{t("pipelines.unknownPipeline", { id: pipelineId })}</option>
)}
</select>
</FieldBlock>
<FieldBlock label={t("pipelines.stageLabel")}>
<select
value={stageId}
onChange={(e) =>
onChange({ pipeline_id: pipelineId, stage_id: e.target.value })
}
className={SELECT_CLASS}
disabled={!pipelineId || stageOptions.length === 0}
>
<option value="">
{pipelineId ? t("pipelines.selectStage") : t("pipelines.selectPipelineFirst")}
</option>
{stageOptions.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
{stageId && pipelineId && !selectedStage && (
<option value={stageId}>{t("pipelines.unknownStage", { id: stageId })}</option>
)}
</select>
</FieldBlock>
</>
)
}
/** 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,
t,
}: {
templateName: string
language: string
onChange: (patch: { template_name: string; language: string }) => void
t: ReturnType<typeof useTranslations>
}) {
const { templates } = useResources()
if (templates.length === 0) {
return (
<>
<FieldBlock label={t("templates.templateNameLabel")}>
<Input
value={templateName}
onChange={(e) =>
onChange({ template_name: e.target.value, language })
}
className="bg-muted text-foreground"
/>
</FieldBlock>
<FieldBlock label={t("templates.languageLabel")}>
<Input
value={language}
onChange={(e) =>
onChange({ template_name: templateName, language: e.target.value })
}
className="bg-muted text-foreground"
/>
</FieldBlock>
</>
)
}
// 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 (
<FieldBlock label={t("templates.templateLabel")}>
<select
value={current}
onChange={(e) => {
const [name, lang] = e.target.value.split("::")
onChange({ template_name: name ?? "", language: lang ?? "" })
}}
className={SELECT_CLASS}
>
<option value="">{t("templates.select")}</option>
{templates.map((tmpl) => {
const lang = tmpl.language ?? "en_US"
return (
<option key={tmpl.id} value={toValue(tmpl.name, lang)}>
{tmpl.name} ({lang})
</option>
)
})}
{current && !hasMatch && (
<option value={current}>
{t("templates.unknown", { name: templateName, lang: language || t("templates.unknownLang") })}
</option>
)}
</select>
</FieldBlock>
)
}
// ------------------------------------------------------------
// Main builder component
// ------------------------------------------------------------
export function AutomationBuilder({ initial }: { initial: BuilderInitial }) {
const router = useRouter()
const t = useTranslations("Automations.builder")
const isEditing = !!initial.id
const [state, setState] = useState<BuilderInitial>(initial)
const [saving, setSaving] = useState(false)
const [expandedId, setExpandedId] = useState<string | null>(null)
function patchTop<K extends keyof BuilderInitial>(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 ?? t("toasts.saveFailed"))
}
return
}
toast.success(isEditing ? t("toasts.saved") : t("toasts.created"))
if (!isEditing && body?.automation?.id) {
router.replace(`/automations/${body.automation.id}/edit`)
}
} finally {
setSaving(false)
}
}
return (
<div className="fixed inset-0 flex flex-col bg-background">
{/* 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. */}
<header className="flex flex-shrink-0 items-center gap-2 border-b border-border bg-card/80 px-3 py-3 sm:gap-3 sm:px-4">
<button
type="button"
onClick={() => router.push("/automations")}
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label={t("backToAutomations")}
>
<ArrowLeft className="h-4 w-4" />
</button>
<input
value={state.name}
onChange={(e) => patchTop("name", e.target.value)}
placeholder={t("untitled")}
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"
/>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="hidden sm:inline">{t("active")}</span>
<Switch
checked={state.is_active}
onCheckedChange={(v) => patchTop("is_active", !!v)}
aria-label={t("activeAria")}
/>
</div>
<Button
onClick={save}
disabled={saving}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{isEditing ? t("save") : t("saveDraft")}
</Button>
</header>
{/* Canvas */}
<div className="relative flex-1 overflow-y-auto">
<div className="absolute inset-0 bg-[radial-gradient(circle,var(--border)_1px,transparent_1px)] [background-size:20px_20px] pointer-events-none" />
<div className="relative mx-auto flex max-w-2xl flex-col items-center gap-0 px-4 py-10">
<ResourcesProvider>
<TriggerCard
type={state.trigger_type}
config={state.trigger_config}
onTypeChange={(tVal) => patchTop("trigger_type", tVal)}
onConfigChange={(c) => patchTop("trigger_config", c)}
t={t}
/>
<StepList
steps={state.steps}
parentPath={[]}
expandedId={expandedId}
setExpandedId={setExpandedId}
updateStep={updateStep}
addStepAt={addStepAt}
deleteStepAt={deleteStepAt}
moveStepAt={moveStepAt}
/>
</ResourcesProvider>
</div>
</div>
</div>
)
}
// ------------------------------------------------------------
// Trigger card
// ------------------------------------------------------------
function TriggerCard({
type,
config,
onTypeChange,
onConfigChange,
t,
}: {
type: AutomationTriggerType
config: Record<string, unknown>
onTypeChange: (t: AutomationTriggerType) => void
onConfigChange: (c: Record<string, unknown>) => void
t: ReturnType<typeof useTranslations>
}) {
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.
<div className="z-10 w-full max-w-[320px] sm:w-80">
<div className="rounded-lg border border-border border-l-4 border-l-blue-500 bg-card shadow-lg">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-3 px-4 py-3 text-left"
>
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-blue-500/10 text-blue-400">
<Zap className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="text-[11px] uppercase tracking-wide text-blue-300">{t("trigger")}</div>
<div className="truncate text-sm font-medium text-foreground">
{t(`triggers.${type}.label`)}
</div>
</div>
<ChevronDown
className={cn("h-4 w-4 text-muted-foreground transition-transform", open && "rotate-180")}
/>
</button>
{open && (
<div className="space-y-3 border-t border-border px-4 py-3">
<div>
<label className="mb-1 block text-xs font-medium text-muted-foreground">
{t("triggerType")}
</label>
<select
value={type}
onChange={(e) => onTypeChange(e.target.value as AutomationTriggerType)}
className="w-full rounded-md border border-border bg-muted px-2 py-1.5 text-sm text-foreground focus:border-primary focus:outline-none"
>
{TRIGGER_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{t(`triggers.${o.value}.label`)}
</option>
))}
</select>
<p className="mt-1 text-[11px] text-muted-foreground">
{t(`triggers.${type}.hint`)}
</p>
</div>
{type === "keyword_match" && (
<KeywordMatchConfig
config={config as unknown as KeywordMatchTriggerConfig}
onChange={onConfigChange}
t={t}
/>
)}
{type === "interactive_reply" && (
<InteractiveReplyConfig config={config} onChange={onConfigChange} t={t} />
)}
{type === "tag_added" && (
<div>
<label className="mb-1 block text-xs font-medium text-muted-foreground">
Tag
</label>
<TagSelect
value={(config.tag_id as string) ?? ""}
onChange={(v) => onConfigChange({ ...config, tag_id: v })}
t={t}
/>
</div>
)}
{type === "time_based" && (
<div>
<label className="mb-1 block text-xs font-medium text-muted-foreground">
{t("schedule")}
</label>
<Input
placeholder="Cron expression or HH:mm"
value={(config.schedule as string) ?? ""}
onChange={(e) =>
onConfigChange({ ...config, schedule: e.target.value })
}
className="bg-muted text-foreground"
/>
<p className="mt-1 text-[11px] text-muted-foreground">
{t("scheduleHint")}
</p>
</div>
)}
</div>
)}
</div>
</div>
)
}
function KeywordMatchConfig({
config,
onChange,
t,
}: {
config: KeywordMatchTriggerConfig
onChange: (c: Record<string, unknown>) => void
t: ReturnType<typeof useTranslations>
}) {
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(", "))
// Persist the default the <select> displays. The dropdown falls back to
// "contains" for display, but leaving it untouched would otherwise omit
// match_type from the saved config — and activation validation then
// rejected it (trigger.match_type). Seed once on mount; the component
// remounts when the trigger type changes, matching the keywords draft.
useEffect(() => {
if (config?.match_type == null) {
onChange({ ...config, match_type: "contains" })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
function commit() {
const parsed = draft
.split(",")
.map((s) => s.trim())
.filter(Boolean)
setDraft(parsed.join(", "))
onChange({ ...config, keywords: parsed })
}
return (
<div className="space-y-2">
<div>
<label className="mb-1 block text-xs font-medium text-muted-foreground">
{t("keywords")}
</label>
<Input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
commit()
}
}}
placeholder={t("keywordsHint")}
className="bg-muted text-foreground"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-muted-foreground">
{t("config.matchType")}
</label>
<select
value={config?.match_type ?? "contains"}
onChange={(e) => onChange({ ...config, match_type: e.target.value as "exact" | "contains" })}
className="w-full rounded-md border border-border bg-muted px-2 py-1.5 text-sm text-foreground focus:outline-none"
>
<option value="contains">{t("config.matchContains")}</option>
<option value="exact">{t("config.matchExact")}</option>
</select>
</div>
</div>
)
}
function InteractiveReplyConfig({
config,
onChange,
t,
}: {
config: Record<string, unknown>
onChange: (c: Record<string, unknown>) => void
t: ReturnType<typeof useTranslations>
}) {
const ids = (config?.reply_ids as string[] | undefined) ?? []
// Same local-draft-then-commit pattern as KeywordMatchConfig so
// commas + spaces survive keystrokes.
const [draft, setDraft] = useState(ids.join(", "))
function commit() {
const parsed = draft
.split(",")
.map((s) => s.trim())
.filter(Boolean)
setDraft(parsed.join(", "))
onChange({ ...config, reply_ids: parsed })
}
return (
<div>
<label className="mb-1 block text-xs font-medium text-muted-foreground">
{t("replyIds")}
</label>
<Input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
commit()
}
}}
placeholder={t("replyIdsHint")}
className="bg-muted font-mono text-foreground"
/>
<p className="mt-1 text-[11px] text-muted-foreground">{t("replyIdsHelp")}</p>
</div>
)
}
// ------------------------------------------------------------
// 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 (
<div className="flex flex-col items-center">
<AddButton onPick={(t) => props.addStepAt(parentScope, 0, t)} />
{steps.map((step, idx) => (
<StepRenderer
key={step.cid}
step={step}
index={idx}
total={steps.length}
parentScope={parentScope}
parentPath={parentPath}
{...rest}
/>
))}
</div>
)
}
function StepRenderer({
step,
index,
total,
parentScope,
parentPath,
...props
}: {
step: BuilderStep
index: number
total: number
parentScope: ParentScope
parentPath: StepPath
} & Omit<StepListProps, "steps" | "parentPath">) {
const t = useTranslations("Automations.builder")
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 (
<>
<div className={cn("z-10 flex flex-col", width)}>
<div
className={cn(
"rounded-lg border border-border border-l-4 bg-card shadow-lg",
meta.border,
)}
>
<button
type="button"
onClick={() => props.setExpandedId(expanded ? null : step.cid)}
className="flex w-full items-center gap-3 px-4 py-3 text-left"
>
<GripVertical className="h-4 w-4 flex-shrink-0 text-muted-foreground" aria-hidden />
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-muted text-muted-foreground">
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="text-[11px] uppercase tracking-wide text-muted-foreground">
{isCondition ? "Condition" : step.step_type === "wait" ? "Wait" : "Action"}
</div>
<div className="truncate text-sm font-medium text-foreground">{t(`steps.${meta.label}`)}</div>
<div className="truncate text-[11px] text-muted-foreground">{previewFor(step)}</div>
</div>
<ChevronDown
className={cn("h-4 w-4 text-muted-foreground transition-transform", expanded && "rotate-180")}
/>
</button>
{expanded && (
<div className="border-t border-border px-4 py-3">
<StepEditor
step={step}
onChange={(next) => props.updateStep(path, () => next)}
/>
<div className="mt-3 flex items-center justify-between gap-2 border-t border-border pt-3">
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
disabled={index === 0}
aria-label="Move up"
onClick={() => props.moveStepAt(path, -1)}
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
disabled={index === total - 1}
aria-label="Move down"
onClick={() => props.moveStepAt(path, 1)}
>
<ArrowDown className="h-4 w-4" />
</Button>
</div>
<Button
variant="destructive"
size="sm"
onClick={() => props.deleteStepAt(path)}
>
<Trash2 className="h-3.5 w-3.5" />
{t("delete", { defaultValue: "Delete" })}
</Button>
</div>
</div>
)}
</div>
{isCondition && (
<ConditionBranches step={step} parentPath={path} {...props} />
)}
</div>
{/* A condition branches into Yes/No (rendered above by
ConditionBranches), so it has no linear "continue" path — adding
the trailing connector here would produce a spurious third output. */}
{!isCondition && (
<AddButton
onPick={(t) => props.addStepAt(parentScope, index + 1, t)}
/>
)}
</>
)
}
function ConditionBranches({
step,
parentPath,
...props
}: {
step: BuilderStep
parentPath: StepPath
} & Omit<StepListProps, "steps" | "parentPath">) {
const t = useTranslations("Automations.builder")
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+.
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
<BranchColumn label={t("branches.yes")} color="text-primary">
<StepList {...props} steps={yes} parentPath={yesPath} />
</BranchColumn>
<BranchColumn label={t("branches.no")} color="text-rose-400">
<StepList {...props} steps={no} parentPath={noPath} />
</BranchColumn>
</div>
)
}
function BranchColumn({
label,
color,
children,
}: {
label: string
color: string
children: React.ReactNode
}) {
return (
<div className="flex flex-col items-center">
<div className={cn("mb-2 text-[11px] font-semibold uppercase", color)}>{label}</div>
{children}
</div>
)
}
function AddButton({ onPick }: { onPick: (t: AutomationStepType) => void }) {
const t = useTranslations("Automations.builder")
return (
<div className="relative flex flex-col items-center">
<div className="h-4 w-[2px] bg-border" aria-hidden />
<DropdownMenu>
<DropdownMenuTrigger
className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-dashed border-border bg-background text-muted-foreground transition-colors hover:border-primary hover:bg-primary/10 hover:text-primary data-[popup-open]:border-primary data-[popup-open]:bg-primary/20 data-[popup-open]:text-primary"
aria-label={t("addStep")}
>
<Plus className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="max-h-80 min-w-56 overflow-y-auto border-border bg-popover"
>
{ADDABLE_STEPS.map((tp) => {
const Icon = STEP_META[tp].icon
return (
<DropdownMenuItem key={tp} onClick={() => onPick(tp)}>
<Icon className="h-4 w-4" />
{t(`steps.${STEP_META[tp].label}`)}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
<div className="h-4 w-[2px] bg-border" aria-hidden />
</div>
)
}
// ------------------------------------------------------------
// Per-step config editor
// ------------------------------------------------------------
function StepEditor({
step,
onChange,
}: {
step: BuilderStep
onChange: (s: BuilderStep) => void
}) {
const t = useTranslations("Automations.builder")
const cfg = step.step_config
const set = (patch: Record<string, unknown>) =>
onChange({ ...step, step_config: { ...cfg, ...patch } })
switch (step.step_type) {
case "send_message":
return (
<FieldBlock label={t("config.messageText")}>
<Textarea
value={(cfg.text as string) ?? ""}
onChange={(e) => set({ text: e.target.value })}
placeholder={t("config.placeholderMessageText")}
className="min-h-24 bg-muted text-foreground"
/>
</FieldBlock>
)
case "send_buttons":
case "send_list":
// The whole step_config IS the interactive payload; the shared
// builder edits it in place (and enforces Meta's limits + preview).
return (
<InteractiveBuilder
value={asInteractive(cfg)}
onChange={(payload) =>
onChange({ ...step, step_config: toStepConfig(payload) })
}
/>
)
case "send_template":
return (
<SendTemplateFields
templateName={(cfg.template_name as string) ?? ""}
language={(cfg.language as string) ?? ""}
onChange={(patch) => set(patch)}
t={t}
/>
)
case "add_tag":
case "remove_tag":
return (
<FieldBlock label={t("config.tagLabel")}>
<TagSelect
value={(cfg.tag_id as string) ?? ""}
onChange={(v) => set({ tag_id: v })}
t={t}
/>
</FieldBlock>
)
case "assign_conversation":
return (
<>
<FieldBlock label={t("config.modeLabel")}>
<select
value={(cfg.mode as string) ?? "round_robin"}
onChange={(e) => set({ mode: e.target.value })}
className="w-full rounded-md border border-border bg-muted px-2 py-1.5 text-sm text-foreground"
>
<option value="round_robin">{t("config.modes.round_robin")}</option>
<option value="specific">{t("config.modes.specific")}</option>
</select>
</FieldBlock>
{cfg.mode === "specific" && (
<FieldBlock label={t("config.agentLabel")}>
<AgentSelect
value={(cfg.agent_id as string) ?? ""}
onChange={(v) => set({ agent_id: v })}
t={t}
/>
</FieldBlock>
)}
</>
)
case "update_contact_field":
return (
<>
<FieldBlock label={t("config.fieldLabel")}>
<ContactFieldSelect
value={(cfg.field as string) ?? "name"}
onChange={(v) => set({ field: v })}
t={t}
/>
</FieldBlock>
<FieldBlock label={t("config.valueLabel")}>
<Input
value={(cfg.value as string) ?? ""}
onChange={(e) => set({ value: e.target.value })}
placeholder={t("config.placeholderValue")}
className="bg-muted text-foreground"
/>
</FieldBlock>
</>
)
case "create_deal":
return (
<>
<DealPipelineFields
pipelineId={(cfg.pipeline_id as string) ?? ""}
stageId={(cfg.stage_id as string) ?? ""}
onChange={(patch) => set(patch)}
t={t}
/>
<FieldBlock label={t("config.titleLabel")}>
<Input
value={(cfg.title as string) ?? ""}
onChange={(e) => set({ title: e.target.value })}
className="bg-muted text-foreground"
/>
</FieldBlock>
<FieldBlock label={t("config.valueLabel")}>
<Input
type="number"
value={(cfg.value as number) ?? 0}
onChange={(e) => set({ value: Number(e.target.value) })}
className="bg-muted text-foreground"
/>
</FieldBlock>
</>
)
case "wait":
return (
<div className="grid grid-cols-2 gap-2">
<FieldBlock label={t("config.amountLabel")}>
<Input
type="number"
min={1}
value={(cfg.amount as number) ?? 1}
onChange={(e) => set({ amount: Math.max(1, Number(e.target.value)) })}
className="bg-muted text-foreground"
/>
</FieldBlock>
<FieldBlock label={t("config.unitLabel")}>
<select
value={(cfg.unit as string) ?? "hours"}
onChange={(e) => set({ unit: e.target.value })}
className="w-full rounded-md border border-border bg-muted px-2 py-1.5 text-sm text-foreground"
>
<option value="minutes">{t("config.units.minutes")}</option>
<option value="hours">{t("config.units.hours")}</option>
<option value="days">{t("config.units.days")}</option>
</select>
</FieldBlock>
</div>
)
case "condition":
return (
<>
<FieldBlock label={t("config.subjectLabel")}>
<select
value={(cfg.subject as string) ?? "tag_presence"}
onChange={(e) => set({ subject: e.target.value })}
className="w-full rounded-md border border-border bg-muted px-2 py-1.5 text-sm text-foreground"
>
<option value="tag_presence">{t("config.subjects.tag_presence")}</option>
<option value="contact_field">{t("config.subjects.contact_field")}</option>
<option value="message_content">{t("config.subjects.message_content")}</option>
<option value="time_of_day">{t("config.subjects.time_of_day")}</option>
</select>
</FieldBlock>
<FieldBlock label={t("config.operandLabel")}>
<Input
placeholder={
cfg.subject === "time_of_day"
? t("config.placeholderTime")
: cfg.subject === "contact_field"
? t("config.placeholderContact")
: cfg.subject === "tag_presence"
? t("config.placeholderTag")
: ""
}
value={(cfg.operand as string) ?? ""}
onChange={(e) => set({ operand: e.target.value })}
className="bg-muted text-foreground"
/>
</FieldBlock>
{(cfg.subject === "contact_field" || cfg.subject === "message_content") && (
<FieldBlock label="Value">
<Input
value={(cfg.value as string) ?? ""}
onChange={(e) => set({ value: e.target.value })}
className="bg-muted text-foreground"
/>
</FieldBlock>
)}
</>
)
case "send_webhook":
return (
<>
<FieldBlock label={t("config.urlLabel")}>
<Input
value={(cfg.url as string) ?? ""}
onChange={(e) => set({ url: e.target.value })}
className="bg-muted text-foreground"
/>
</FieldBlock>
<FieldBlock label={t("config.bodyTemplateLabel")}>
<Textarea
value={(cfg.body_template as string) ?? ""}
onChange={(e) => set({ body_template: e.target.value })}
className="min-h-20 bg-muted font-mono text-xs text-foreground"
/>
</FieldBlock>
</>
)
case "close_conversation":
return (
<p className="text-xs text-muted-foreground">
{t("config.closeConversationHint", { defaultValue: "Sets the conversation status to \"closed\". No configuration needed." })}
</p>
)
default:
return null
}
}
function FieldBlock({
label,
children,
}: {
label: string
children: React.ReactNode
}) {
return (
<div className="mb-2 last:mb-0">
<label className="mb-1 block text-xs font-medium text-muted-foreground">{label}</label>
{children}
</div>
)
}
function previewFor(step: BuilderStep): string {
switch (step.step_type) {
case "send_message":
return (step.step_config.text as string) || "no text yet"
case "send_buttons":
case "send_list":
return interactivePayloadPreviewText(asInteractive(step.step_config)) || "no body yet"
case "send_template":
return (step.step_config.template_name as string) || "pick a template"
case "wait":
return `${step.step_config.amount ?? "?"} ${step.step_config.unit ?? ""}`
case "condition":
return `when ${step.step_config.subject ?? "?"}`
case "send_webhook":
return (step.step_config.url as string) || "no url"
default:
return ""
}
}
// ------------------------------------------------------------
// Tree mutation helpers
// ------------------------------------------------------------
function insertAt(
steps: BuilderStep[],
parent: ParentScope,
index: number,
node: BuilderStep,
): BuilderStep[] {
if (parent.kind === "root") {
const copy = [...steps]
copy.splice(index, 0, node)
return copy
}
return steps.map((s) => {
if (s.cid !== parent.parentCid || !s.branches) return s
const list = [...s.branches[parent.branch]]
list.splice(index, 0, node)
return { ...s, branches: { ...s.branches, [parent.branch]: list } }
})
}
function mapAtPath(
steps: BuilderStep[],
path: StepPath,
updater: (s: BuilderStep) => BuilderStep,
): BuilderStep[] {
if (path.length === 0) return steps
const head = path[0]
const rest = path.slice(1)
if (head.kind === "root") {
return steps.map((s, i) => {
if (i !== head.index) return s
return rest.length === 0
? updater(s)
: { ...s, branches: walkBranches(s.branches, rest, updater) }
})
}
return steps.map((s) => {
if (s.cid !== head.parentCid || !s.branches) return s
const bucket = s.branches[head.branch]
const updated = bucket.map((child, i) => {
if (i !== head.index) return child
return rest.length === 0
? updater(child)
: { ...child, branches: walkBranches(child.branches, rest, updater) }
})
return { ...s, branches: { ...s.branches, [head.branch]: updated } }
})
}
function walkBranches(
branches: BuilderStep["branches"],
path: StepPath,
updater: (s: BuilderStep) => BuilderStep,
): BuilderStep["branches"] {
if (!branches) return branches
const head = path[0]
if (head.kind !== "branch") return branches
const bucket = branches[head.branch]
const rest = path.slice(1)
const updated = bucket.map((child, i) => {
if (i !== head.index) return child
return rest.length === 0
? updater(child)
: { ...child, branches: walkBranches(child.branches, rest, updater) }
})
return { ...branches, [head.branch]: updated }
}
function removeAt(steps: BuilderStep[], path: StepPath): BuilderStep[] {
if (path.length === 0) return steps
const head = path[0]
const rest = path.slice(1)
if (head.kind === "root") {
if (rest.length === 0) return steps.filter((_, i) => i !== head.index)
return steps.map((s, i) =>
i !== head.index ? s : { ...s, branches: removeFromBranches(s.branches, rest) },
)
}
return steps.map((s) => {
if (s.cid !== head.parentCid || !s.branches) return s
const bucket = s.branches[head.branch]
const next =
rest.length === 0
? bucket.filter((_, i) => i !== head.index)
: bucket.map((child, i) =>
i !== head.index
? child
: { ...child, branches: removeFromBranches(child.branches, rest) },
)
return { ...s, branches: { ...s.branches, [head.branch]: next } }
})
}
function removeFromBranches(
branches: BuilderStep["branches"],
path: StepPath,
): BuilderStep["branches"] {
if (!branches) return branches
const head = path[0]
if (head.kind !== "branch") return branches
const rest = path.slice(1)
const bucket = branches[head.branch]
const next =
rest.length === 0
? bucket.filter((_, i) => i !== head.index)
: bucket.map((child, i) =>
i !== head.index
? child
: { ...child, branches: removeFromBranches(child.branches, rest) },
)
return { ...branches, [head.branch]: next }
}
function moveAt(
steps: BuilderStep[],
path: StepPath,
direction: -1 | 1,
): BuilderStep[] {
if (path.length === 0) return steps
const head = path[0]
const rest = path.slice(1)
const swap = <T,>(arr: T[], i: number) => {
const j = i + direction
if (j < 0 || j >= arr.length) return arr
const copy = [...arr]
;[copy[i], copy[j]] = [copy[j], copy[i]]
return copy
}
if (head.kind === "root") {
if (rest.length === 0) return swap(steps, head.index)
return steps.map((s, i) =>
i !== head.index ? s : { ...s, branches: moveInBranches(s.branches, rest, direction) },
)
}
return steps.map((s) => {
if (s.cid !== head.parentCid || !s.branches) return s
const bucket = s.branches[head.branch]
const next = rest.length === 0 ? swap(bucket, head.index) : bucket
return { ...s, branches: { ...s.branches, [head.branch]: next } }
})
}
function moveInBranches(
branches: BuilderStep["branches"],
path: StepPath,
direction: -1 | 1,
): BuilderStep["branches"] {
if (!branches) return branches
const head = path[0]
if (head.kind !== "branch") return branches
const rest = path.slice(1)
const bucket = branches[head.branch]
const swap = <T,>(arr: T[], i: number) => {
const j = i + direction
if (j < 0 || j >= arr.length) return arr
const copy = [...arr]
;[copy[i], copy[j]] = [copy[j], copy[i]]
return copy
}
const next = rest.length === 0 ? swap(bucket, head.index) : bucket
return { ...branches, [head.branch]: next }
}
// ------------------------------------------------------------
// Serialize builder tree → API payload (flattened shape)
// ------------------------------------------------------------
interface ApiStep {
step_type: string
step_config: Record<string, unknown>
branches?: { yes?: ApiStep[]; no?: ApiStep[] }
}
export function toApiSteps(steps: BuilderStep[]): ApiStep[] {
return steps.map((s) => ({
step_type: s.step_type,
step_config: s.step_config,
branches: s.branches
? { yes: toApiSteps(s.branches.yes), no: toApiSteps(s.branches.no) }
: undefined,
}))
}
/**
* Convert server-returned step tree (from loadStepsTree) into the
* builder-local shape with client ids.
*/
export interface ServerStepNode {
id: string
step_type: string
step_config: Record<string, unknown>
branches: { yes: ServerStepNode[]; no: ServerStepNode[] }
}
export function fromServerSteps(nodes: ServerStepNode[]): BuilderStep[] {
return nodes.map((n) => ({
cid: cid(),
step_type: n.step_type as AutomationStepType,
step_config: n.step_config ?? {},
branches:
n.step_type === "condition"
? {
yes: fromServerSteps(n.branches?.yes ?? []),
no: fromServerSteps(n.branches?.no ?? []),
}
: undefined,
}))
}