diff --git a/web/src/components/app/campaigns/sequences/CampaignFlow.tsx b/web/src/components/app/campaigns/sequences/CampaignFlow.tsx index 9c172a9e4..4616a6690 100644 --- a/web/src/components/app/campaigns/sequences/CampaignFlow.tsx +++ b/web/src/components/app/campaigns/sequences/CampaignFlow.tsx @@ -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 )} + {(action.type === "create_deal" || action.type === "move_deal_stage") && ( +
+
+ + + setAction((a) => ({ ...a, deal_pipeline_id: pipelineId, deal_stage_id: stageId })) + } + /> + {action.type === "move_deal_stage" && ( +

+ Moves the contact's most recent open deal in this pipeline to this stage. If they have no + open deal here, nothing happens. +

+ )} +
+ + {action.type === "create_deal" && ( + <> +
+
+ + + setAction((a) => ({ ...a, deal_name: (a.deal_name ?? "") + token })) + } + /> +
+ setAction((a) => ({ ...a, deal_name: v }))} + placeholder="{{.Company}} ({{.FirstName}})" + className="w-full max-w-[320px]" + /> +

+ Supports the same {"{{.FirstName}}"} / {"{{.Company}}"} variables as your email copy. +

+
+
+
+ + + setAction((a) => ({ ...a, deal_value: n > 0 ? n : undefined })) + } + min={0} + max={1_000_000_000} + className="w-36" + /> +
+
+ + setAction((a) => ({ ...a, deal_currency: c }))} + /> +
+
+ + )} +
+ )} +
+ {open && ( +
+ {DEAL_NAME_VARIABLES.map((v) => ( + + ))} +
+ )} +
+ ); +} + +// 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(null); + useClickOutside(ref, () => setOpen(false)); + return ( +
+ + {open && ( +
+ {DEAL_CURRENCIES.map((c) => ( + + ))} +
+ )} +
+ ); +} diff --git a/web/src/components/app/crm/DealStagePicker.tsx b/web/src/components/app/crm/DealStagePicker.tsx new file mode 100644 index 000000000..a6f23bc21 --- /dev/null +++ b/web/src/components/app/crm/DealStagePicker.tsx @@ -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 ( +

+ You don't have a CRM pipeline yet. Create one under CRM, then come back to choose where deals land. +

+ ); + } + + return ( +
+
+

Pipeline

+ ({ id: p.id, name: p.name }))} + value={pipelineId} + onChange={pickPipeline} + /> +
+
+

Stage

+ onChange({ pipelineId: pipelineId ?? "", stageId: id })} + /> +
+
+ ); +} + +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 ( + + + + + + {pipelines.map((p) => ( + onChange(p.id)} selected={p.id === value}> + {p.name} + + ))} + + + ); +} + +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 ( + + + + + + {stages.map((s) => ( + onChange(s.id)} + selected={s.id === value} + icon={ + + } + > + {s.name} + + ))} + + + ); +} diff --git a/web/src/lib/api/models/app/campaigns/sequences/Action.ts b/web/src/lib/api/models/app/campaigns/sequences/Action.ts index e90415692..ffb05cd6b 100644 --- a/web/src/lib/api/models/app/campaigns/sequences/Action.ts +++ b/web/src/lib/api/models/app/campaigns/sequences/Action.ts @@ -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" }