mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-28 08:02:30 +00:00
feat: add CRM deal sequence editor actions
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user