feat: launch automations from campaigns

This commit is contained in:
Matthew Meszaros
2026-06-08 12:03:58 +02:00
parent e0d8041fbc
commit 99ce8dd223
2 changed files with 126 additions and 3 deletions
@@ -75,8 +75,10 @@ import { SelectMenu, type SelectOption } from "@/components/ui/select-menu";
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
import SequenceView from "./SequenceView";
import StepVariants from "./StepVariants";
import CategoryPicker from "@/components/app/contacts/CategoryPicker";
import type { SequenceAction, SequenceActionType } from "@/lib/api/models/app/campaigns/sequences/Action";
import type { ActionKV, SequenceAction, SequenceActionType } from "@/lib/api/models/app/campaigns/sequences/Action";
import { useAutomations } from "@/lib/api/hooks/app/automations/useAutomations";
import TaskTypePicker from "@/components/app/crm/TaskTypePicker";
import DealStagePicker from "@/components/app/crm/DealStagePicker";
import useMembers from "@/lib/api/hooks/app/organizations/useMembers";
@@ -388,6 +390,7 @@ const ACTION_META: Record<string, { label: string; Icon: typeof ClockIcon; tint:
move_deal_stage: { label: "Move deal stage", Icon: ArrowRightLeftIcon, tint: "text-sky-600" },
unsubscribe: { label: "Unsubscribe", Icon: BellOffIcon, tint: "text-rose-600" },
notify: { label: "Notify", Icon: SendIcon, tint: "text-sky-600" },
run_automation: { label: "Run automation", Icon: ZapIcon, tint: "text-indigo-600" },
};
// actionSummary is the one-line subtitle shown on an action node.
@@ -406,6 +409,8 @@ function actionSummary(a?: SequenceAction | null): string {
return "Unsubscribe the contact";
case "notify":
return "Send a notification";
case "run_automation":
return a.automation_id ? "Launch an automation" : "Pick an automation…";
default:
return "Action";
}
@@ -1372,7 +1377,15 @@ export default function CampaignFlow({ campaignId }: { campaignId: string }) {
{editStep.kind !== "email" ? (
<ActionEditor campaignId={campaignId} sequence={editStep} onSaved={invalidate} />
) : (
<SequenceView campaignId={campaignId} sequence={editStep} index={editIndex} />
<div className="space-y-4">
<SequenceView campaignId={campaignId} sequence={editStep} index={editIndex} />
<StepVariants
campaignId={campaignId}
sequenceId={editStep.id}
baseSubject={editStep.subject ?? ""}
baseBodyHtml={editStep.body_html ?? ""}
/>
</div>
)}
</div>
</div>
@@ -1722,6 +1735,7 @@ const ADD_ACTION_OPTIONS: { type: SequenceActionType; label: string }[] = [
{ type: "move_deal_stage", label: "Move deal stage" },
{ type: "unsubscribe", label: "Unsubscribe" },
{ type: "notify", label: "Notify (webhook)" },
{ type: "run_automation", label: "Run automation" },
];
function AddNodeMenu({
@@ -1812,6 +1826,9 @@ function defaultActionFor(type: SequenceActionType): SequenceAction {
if (type === "move_deal_stage") {
return { type };
}
if (type === "run_automation") {
return { type, automation_values: [] };
}
return { type };
}
@@ -2098,6 +2115,8 @@ function ActionEditor({
</div>
)}
{action.type === "run_automation" && <RunAutomationFields action={action} setAction={setAction} />}
<div className="flex items-center justify-end pt-1">
<button
type="button"
@@ -2112,6 +2131,98 @@ function ActionEditor({
);
}
// RunAutomationFields — pick an automation to launch + the templated key/value
// inputs passed to it as event data (values render against the contact).
function RunAutomationFields({
action,
setAction,
}: {
action: SequenceAction;
setAction: React.Dispatch<React.SetStateAction<SequenceAction>>;
}) {
const { data } = useAutomations();
const automations = data?.automations ?? [];
const options: SelectOption[] = automations.map((a) => ({ value: a.id, label: a.name || "Untitled automation" }));
const values = action.automation_values ?? [];
const setValues = (next: ActionKV[]) => setAction((a) => ({ ...a, automation_values: next }));
const updateRow = (i: number, patch: Partial<ActionKV>) =>
setValues(values.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
const addRow = () => setValues([...values, { key: "", value: "" }]);
const removeRow = (i: number) => setValues(values.filter((_, idx) => idx !== i));
return (
<div className="space-y-4">
<div>
<Label>Automation to run</Label>
<SelectMenu
value={action.automation_id ?? ""}
onChange={(id) => setAction((a) => ({ ...a, automation_id: id }))}
options={options}
placeholder={options.length ? "Choose an automation…" : "No automations yet"}
className="w-full max-w-[320px]"
fullWidth
/>
<p className="mt-1.5 text-[11px] text-slate-400">
Launches the automation's flow for this contact when they reach this step. The automation receives{" "}
<span className="font-mono text-slate-500">contact_email</span>,{" "}
<span className="font-mono text-slate-500">first_name</span>,{" "}
<span className="font-mono text-slate-500">last_name</span>,{" "}
<span className="font-mono text-slate-500">company</span>,{" "}
<span className="font-mono text-slate-500">campaign_name</span> plus your values below reference them as{" "}
<span className="font-mono text-slate-500">{"{{key}}"}</span> in the automation's actions.
</p>
</div>
<div>
<div className="mb-1.5 flex items-center justify-between gap-2">
<Label className="mb-0">Pass values (optional)</Label>
<button
type="button"
onClick={addRow}
className="inline-flex h-6 items-center gap-1 rounded-md border border-slate-200 bg-white px-2 text-[11.5px] font-medium text-slate-600 transition-colors hover:border-slate-300 hover:text-slate-900"
>
<PlusIcon className="w-3 h-3" /> Add value
</button>
</div>
{values.length === 0 ? (
<p className="text-[11px] text-slate-400">
Add key/value pairs to pass into the automation. Values support {"{{.FirstName}}"} / {"{{.Company}}"}.
</p>
) : (
<div className="space-y-1.5">
{values.map((row, i) => (
<div key={i} className="flex items-center gap-1.5">
<TextInput
value={row.key}
onChange={(v) => updateRow(i, { key: v })}
placeholder="key"
className="w-28 shrink-0"
/>
<span className="text-slate-300">=</span>
<TextInput
value={row.value}
onChange={(v) => updateRow(i, { value: v })}
placeholder="{{.FirstName}}"
className="flex-1 min-w-0"
/>
<DealNameVariableMenu onPick={(token) => updateRow(i, { value: (row.value ?? "") + token })} />
<button
type="button"
onClick={() => removeRow(i)}
title="Remove"
className="inline-flex size-6 shrink-0 items-center justify-center rounded text-slate-300 transition-colors hover:bg-rose-50 hover:text-rose-600"
>
<Trash2Icon className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}
</div>
</div>
);
}
// AssigneePicker — choose the teammate a campaign-created task is assigned to.
// `null` means "campaign owner" (the executor's fallback). Values are user ids.
function AssigneePicker({
@@ -9,7 +9,15 @@ export type SequenceActionType =
| "notify"
| "create_task"
| "create_deal"
| "move_deal_stage";
| "move_deal_stage"
| "run_automation";
// One templated input passed to a launched automation (value supports the same
// {{.FirstName}}/{{.Company}} contact templating campaign copy uses).
export interface ActionKV {
key: string;
value: string;
}
export interface SequenceAction {
type: SequenceActionType;
@@ -35,4 +43,8 @@ export interface SequenceAction {
deal_name?: string;
deal_value?: number;
deal_currency?: string; // ISO code, defaults to "USD"
// run_automation — launch an automation when the lead reaches this step,
// passing templated key/value inputs as the automation's event data.
automation_id?: string | null;
automation_values?: ActionKV[];
}