feat: add CRM deal sequence editor actions

This commit is contained in:
Matthew Meszaros
2026-06-07 11:50:00 +02:00
parent 87dc3b2647
commit 650b7d6400
3 changed files with 344 additions and 1 deletions
@@ -15,13 +15,16 @@
import React from "react";
import {
ArrowRightLeftIcon,
BellOffIcon,
BracesIcon,
CheckSquareIcon,
ChevronDownIcon,
ChevronUpIcon,
ClockIcon,
FlagIcon,
GitBranchIcon,
HandshakeIcon,
Loader2Icon,
MailIcon,
PlusIcon,
@@ -69,8 +72,14 @@ import SequenceView from "./SequenceView";
import CategoryPicker from "@/components/app/contacts/CategoryPicker";
import type { SequenceAction, SequenceActionType } from "@/lib/api/models/app/campaigns/sequences/Action";
import TaskTypePicker from "@/components/app/crm/TaskTypePicker";
import DealStagePicker from "@/components/app/crm/DealStagePicker";
import useMembers from "@/lib/api/hooks/app/organizations/useMembers";
// Personalization tokens available in templated copy. Mirrors SequenceView's
// VARIABLES so a deal name can use the same {{.FirstName}}/{{.Company}} tokens
// every other campaign copy field does.
const DEAL_NAME_VARIABLES = ["{{.FirstName}}", "{{.LastName}}", "{{.Company}}", "{{.Email}}"];
const STOP_ID = "__stop__";
const IF_PREFIX = "if-";
const NODE_W = 248;
@@ -314,6 +323,8 @@ const ACTION_META: Record<string, { label: string; Icon: typeof ClockIcon; tint:
add_tag: { label: "Add tag", Icon: TagIcon, tint: "text-emerald-600" },
remove_tag: { label: "Remove tag", Icon: TagIcon, tint: "text-amber-600" },
create_task: { label: "Create task", Icon: CheckSquareIcon, tint: "text-violet-600" },
create_deal: { label: "Create deal", Icon: HandshakeIcon, tint: "text-emerald-600" },
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" },
};
@@ -326,6 +337,10 @@ function actionSummary(a?: SequenceAction | null): string {
return a.category_id ? "Add a tag" : "Pick a tag…";
case "remove_tag":
return a.category_id ? "Remove a tag" : "Pick a tag…";
case "create_deal":
return a.deal_pipeline_id && a.deal_stage_id ? "Create a CRM deal" : "Pick a pipeline and stage…";
case "move_deal_stage":
return a.deal_pipeline_id && a.deal_stage_id ? "Move the deal forward" : "Pick a pipeline and stage…";
case "unsubscribe":
return "Unsubscribe the contact";
case "notify":
@@ -1385,6 +1400,8 @@ const ADD_ACTION_OPTIONS: { type: SequenceActionType; label: string }[] = [
{ type: "add_tag", label: "Add tag" },
{ type: "remove_tag", label: "Remove tag" },
{ type: "create_task", label: "Create task" },
{ type: "create_deal", label: "Create deal" },
{ type: "move_deal_stage", label: "Move deal stage" },
{ type: "unsubscribe", label: "Unsubscribe" },
{ type: "notify", label: "Notify (webhook)" },
];
@@ -1471,6 +1488,12 @@ function defaultActionFor(type: SequenceActionType): SequenceAction {
if (type === "create_task") {
return { type, task_priority: "medium", task_due_offset_days: 1 };
}
if (type === "create_deal") {
return { type, deal_currency: "USD", deal_name: "{{.Company}} ({{.FirstName}})" };
}
if (type === "move_deal_stage") {
return { type };
}
return { type };
}
@@ -1691,6 +1714,72 @@ function ActionEditor({
</div>
)}
{(action.type === "create_deal" || action.type === "move_deal_stage") && (
<div className="space-y-4">
<div>
<Label>{action.type === "create_deal" ? "Create the deal in" : "Move the deal to"}</Label>
<DealStagePicker
pipelineId={action.deal_pipeline_id}
stageId={action.deal_stage_id}
onChange={({ pipelineId, stageId }) =>
setAction((a) => ({ ...a, deal_pipeline_id: pipelineId, deal_stage_id: stageId }))
}
/>
{action.type === "move_deal_stage" && (
<p className="mt-1.5 text-[11px] text-slate-400">
Moves the contact's most recent open deal in this pipeline to this stage. If they have no
open deal here, nothing happens.
</p>
)}
</div>
{action.type === "create_deal" && (
<>
<div>
<div className="mb-1.5 flex items-center justify-between gap-2">
<Label className="mb-0">Deal name</Label>
<DealNameVariableMenu
onPick={(token) =>
setAction((a) => ({ ...a, deal_name: (a.deal_name ?? "") + token }))
}
/>
</div>
<TextInput
value={action.deal_name ?? ""}
onChange={(v) => setAction((a) => ({ ...a, deal_name: v }))}
placeholder="{{.Company}} ({{.FirstName}})"
className="w-full max-w-[320px]"
/>
<p className="mt-1.5 text-[11px] text-slate-400">
Supports the same {"{{.FirstName}}"} / {"{{.Company}}"} variables as your email copy.
</p>
</div>
<div className="flex flex-wrap items-end gap-4">
<div>
<Label>Value (optional)</Label>
<NumberInput
value={action.deal_value ?? 0}
onChange={(n) =>
setAction((a) => ({ ...a, deal_value: n > 0 ? n : undefined }))
}
min={0}
max={1_000_000_000}
className="w-36"
/>
</div>
<div>
<Label>Currency</Label>
<CurrencyPicker
value={action.deal_currency ?? "USD"}
onChange={(c) => setAction((a) => ({ ...a, deal_currency: c }))}
/>
</div>
</div>
</>
)}
</div>
)}
<div className="flex items-center justify-end pt-1">
<button
type="button"
@@ -1764,3 +1853,83 @@ function AssigneePicker({
</div>
);
}
// DealNameVariableMenu — a compact "insert variable" trigger for the deal-name
// field, mirroring the VariableMenu affordance the email subject/body use.
// Appends a {{.Token}} into the deal name so it can personalize per contact.
function DealNameVariableMenu({ onPick }: { onPick: (token: string) => void }) {
const [open, setOpen] = React.useState(false);
const ref = React.useRef<HTMLDivElement>(null);
useClickOutside(ref, () => setOpen(false));
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen((o) => !o)}
title="Insert a personalization variable"
className="inline-flex h-7 items-center gap-1 rounded px-1.5 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900"
>
<BracesIcon className="w-3.5 h-3.5" />
<ChevronDownIcon className="w-3 h-3" />
</button>
{open && (
<div className="absolute right-0 top-full z-30 mt-1 w-44 overflow-hidden rounded-md border border-slate-200 bg-white py-1 shadow-[0_12px_32px_-8px_rgba(15,23,42,0.18)]">
{DEAL_NAME_VARIABLES.map((v) => (
<button
key={v}
type="button"
onClick={() => {
onPick(v);
setOpen(false);
}}
className="flex w-full items-center px-2.5 py-1.5 text-left font-mono text-[11.5px] text-slate-700 transition-colors hover:bg-slate-100"
>
{v}
</button>
))}
</div>
)}
</div>
);
}
// CurrencyPicker — a small themed dropdown for the deal currency (ISO code).
// Defaults to USD; the value persisted is the bare ISO code (e.g. "USD").
const DEAL_CURRENCIES = ["USD", "EUR", "GBP", "CAD", "AUD", "JPY", "CHF", "SEK", "INR", "BRL"];
function CurrencyPicker({ value, onChange }: { value: string; onChange: (c: string) => void }) {
const [open, setOpen] = React.useState(false);
const ref = React.useRef<HTMLDivElement>(null);
useClickOutside(ref, () => setOpen(false));
return (
<div ref={ref} className="relative inline-flex">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="inline-flex h-7 w-24 items-center gap-1.5 rounded-md border border-slate-200 bg-white px-2.5 text-[12px] text-slate-700 transition-colors hover:border-slate-300 hover:text-slate-900"
>
<span className="flex-1 truncate text-left">{value || "USD"}</span>
<ChevronDownIcon className="w-3 h-3 text-slate-400" />
</button>
{open && (
<div className="absolute left-0 top-full z-30 mt-1 max-h-56 w-24 overflow-y-auto rounded-md border border-slate-200 bg-white py-1 shadow-[0_12px_32px_-8px_rgba(15,23,42,0.18)]">
{DEAL_CURRENCIES.map((c) => (
<button
key={c}
type="button"
onClick={() => {
onChange(c);
setOpen(false);
}}
className={`flex w-full items-center px-2.5 py-1.5 text-left text-[12px] transition-colors hover:bg-slate-100 ${
c === value ? "font-medium text-slate-900" : "text-slate-700"
}`}
>
{c}
</button>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,156 @@
// DealStagePicker — a paired pipeline + stage selector for CRM deal automation.
// Mirrors the inline StagePicker pattern used in the unibox ContactContextPanel
// (PopoverMenu trigger + coloured stage dots), but adds a pipeline selector and
// loads the org's pipelines itself. Used by the campaign "Create deal" /
// "Move deal stage" action editors.
//
// The pipeline + stage are persisted as the action's deal_pipeline_id /
// deal_stage_id. When the pipeline changes, the stage resets to that pipeline's
// first stage so the pair always stays consistent.
import React from "react";
import { ChevronDownIcon } from "lucide-react";
import {
PopoverMenu,
PopoverMenuTrigger,
PopoverMenuContent,
PopoverMenuItem,
} from "@/components/ui/popover-menu";
import usePipelines from "@/lib/api/hooks/app/crm/pipelines/usePipelines";
import type { Stage } from "@/lib/api/models/app/crm/Pipeline";
function sortedStages(stages: Stage[] | undefined): Stage[] {
return stages ? [...stages].sort((a, b) => a.position - b.position) : [];
}
export default function DealStagePicker({
pipelineId,
stageId,
onChange,
}: {
pipelineId?: string;
stageId?: string;
onChange: (next: { pipelineId: string; stageId: string }) => void;
}) {
const { data: pipelines = [], isPending } = usePipelines();
const pipeline = pipelines.find((p) => p.id === pipelineId);
const stages = sortedStages(pipeline?.stages);
const pickPipeline = (id: string) => {
const p = pipelines.find((x) => x.id === id);
const first = sortedStages(p?.stages)[0];
onChange({ pipelineId: id, stageId: first?.id ?? "" });
};
if (!isPending && pipelines.length === 0) {
return (
<p className="rounded-md border border-slate-200 bg-slate-50/60 px-3 py-2.5 text-[11.5px] leading-relaxed text-slate-600">
You don't have a CRM pipeline yet. Create one under CRM, then come back to choose where deals land.
</p>
);
}
return (
<div className="grid grid-cols-2 gap-2">
<div>
<p className="mb-1 text-[10px] font-medium uppercase tracking-[0.14em] text-slate-400">Pipeline</p>
<PipelineSelect
pipelines={pipelines.map((p) => ({ id: p.id, name: p.name }))}
value={pipelineId}
onChange={pickPipeline}
/>
</div>
<div>
<p className="mb-1 text-[10px] font-medium uppercase tracking-[0.14em] text-slate-400">Stage</p>
<StageSelect
stages={stages}
value={stageId}
disabled={!pipeline}
onChange={(id) => onChange({ pipelineId: pipelineId ?? "", stageId: id })}
/>
</div>
</div>
);
}
function PipelineSelect({
pipelines,
value,
onChange,
}: {
pipelines: { id: string; name: string }[];
value?: string;
onChange: (id: string) => void;
}) {
const [open, setOpen] = React.useState(false);
const cur = pipelines.find((p) => p.id === value);
return (
<PopoverMenu open={open} onOpenChange={setOpen} align="start">
<PopoverMenuTrigger asChild>
<button
type="button"
className="h-7 w-full px-2 rounded-md border border-slate-200 hover:border-slate-300 bg-white text-[12px] text-slate-700 inline-flex items-center gap-1.5 transition-colors"
>
<span className="truncate flex-1 text-left">{cur?.name ?? "Pick a pipeline…"}</span>
<ChevronDownIcon className="w-3 h-3 text-slate-400 shrink-0" />
</button>
</PopoverMenuTrigger>
<PopoverMenuContent minWidth={180} className="max-h-56 overflow-y-auto">
{pipelines.map((p) => (
<PopoverMenuItem key={p.id} onSelect={() => onChange(p.id)} selected={p.id === value}>
{p.name}
</PopoverMenuItem>
))}
</PopoverMenuContent>
</PopoverMenu>
);
}
function StageSelect({
stages,
value,
disabled,
onChange,
}: {
stages: Stage[];
value?: string;
disabled?: boolean;
onChange: (id: string) => void;
}) {
const [open, setOpen] = React.useState(false);
const cur = stages.find((s) => s.id === value);
return (
<PopoverMenu open={open} onOpenChange={setOpen} align="start">
<PopoverMenuTrigger asChild>
<button
type="button"
disabled={disabled || stages.length === 0}
className="h-7 w-full px-2 rounded-md border border-slate-200 hover:border-slate-300 bg-white text-[12px] text-slate-700 inline-flex items-center gap-1.5 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
>
<span className="size-1.5 rounded-full shrink-0" style={{ backgroundColor: cur?.color || "#cbd5e1" }} />
<span className="truncate flex-1 text-left">{cur?.name ?? "Pick a stage…"}</span>
<ChevronDownIcon className="w-3 h-3 text-slate-400 shrink-0" />
</button>
</PopoverMenuTrigger>
<PopoverMenuContent minWidth={180} className="max-h-56 overflow-y-auto">
{stages.map((s) => (
<PopoverMenuItem
key={s.id}
onSelect={() => onChange(s.id)}
selected={s.id === value}
icon={
<span
className="size-2 rounded-full block"
style={{ backgroundColor: s.color || "#94a3b8" }}
/>
}
>
{s.name}
</PopoverMenuItem>
))}
</PopoverMenuContent>
</PopoverMenu>
);
}
@@ -2,7 +2,14 @@
// Stored in the sequence's `action` jsonb column. Spacing/delays are NOT actions:
// per-step "wait before sending" (wait_after) covers that, so there is no
// standalone wait node.
export type SequenceActionType = "add_tag" | "remove_tag" | "unsubscribe" | "notify" | "create_task";
export type SequenceActionType =
| "add_tag"
| "remove_tag"
| "unsubscribe"
| "notify"
| "create_task"
| "create_deal"
| "move_deal_stage";
export interface SequenceAction {
type: SequenceActionType;
@@ -17,4 +24,15 @@ export interface SequenceAction {
task_priority?: "low" | "medium" | "high" | "urgent";
task_assigned_to?: string | null;
task_due_offset_days?: number | null;
// create_deal / move_deal_stage — CRM deal automation. create_deal opens a
// new deal for the contact in deal_pipeline_id/deal_stage_id; move_deal_stage
// moves the contact's most-recent OPEN deal in deal_pipeline_id to
// deal_stage_id (no-op when the contact has no open deal there).
deal_pipeline_id?: string;
deal_stage_id?: string;
// deal_name supports the same {{.FirstName}}/{{.Company}} templating other
// campaign copy uses. Only meaningful for create_deal.
deal_name?: string;
deal_value?: number;
deal_currency?: string; // ISO code, defaults to "USD"
}