diff --git a/web/src/components/app/campaigns/sequences/CampaignFlow.tsx b/web/src/components/app/campaigns/sequences/CampaignFlow.tsx
index 2e23bdcd..481279a0 100644
--- a/web/src/components/app/campaigns/sequences/CampaignFlow.tsx
+++ b/web/src/components/app/campaigns/sequences/CampaignFlow.tsx
@@ -14,6 +14,7 @@
// - Nothing connects automatically; a step with no outgoing path ends in STOP.
import React from "react";
+import { createPortal } from "react-dom";
import {
ArrowRightLeftIcon,
BellOffIcon,
@@ -317,8 +318,7 @@ function StepNode({ data, selected }: NodeProps) {
On reply
- {/* Right dot = start an "if" branch; bottom dot = plain "go there". */}
-
+ {/* One output dot: drag it out to add the next step, action, or condition. */}
);
@@ -472,14 +472,55 @@ function ActionNode({ data, selected }: NodeProps) {
Ends here
) : null}
- {/* Same handles as a step: right = "if" branch, bottom = "go there". */}
-
+ {/* One output dot: drag it out to add the next step, action, or condition. */}
);
}
-const nodeTypes = { step: StepNode, ifcond: IfNode, stop: StopNode, action: ActionNode };
+type ConditionNodeData = { label: string; endsHere: boolean; orphan: boolean; onDelete: () => void };
+
+// A Condition node is a pure router (no email, no action): it just branches.
+// Drag from it to add conditional paths, and chain Condition nodes to build
+// nested decision trees. Persisted as a no-op step (kind "wait").
+function ConditionNode({ data, selected }: NodeProps) {
+ const d = data as ConditionNodeData;
+ return (
+
+
+
+
+
+
+
+ {d.label || "Condition"}
+
+
+
+
+ {d.endsHere ? "Drag out to add the branches" : "Routes by your conditions"}
+
+ {/* One output dot: drag out to add each conditional path. */}
+
+
+ );
+}
+
+const nodeTypes = { step: StepNode, ifcond: IfNode, stop: StopNode, action: ActionNode, condition: ConditionNode };
// Convergent edge.
// Several branches can route to the same next step (many in -> one node). They
@@ -561,6 +602,9 @@ export default function CampaignFlow({ campaignId }: { campaignId: string }) {
const [selectedEdge, setSelectedEdge] = React.useState<{ sourceId: string; branchId: string } | null>(null);
const [editStepId, setEditStepId] = React.useState(null);
const [adding, setAdding] = React.useState(false);
+ // When you drag a node's dot out to empty canvas, we open a create menu at
+ // the drop point instead of immediately making an email step.
+ const [dragCreate, setDragCreate] = React.useState<{ x: number; y: number; sourceId: string } | null>(null);
// While dragging a node, kill the position transition so the drag is 1:1.
const [dragging, setDragging] = React.useState(false);
// Editing the sequence flow (add a step, drag a node, draw a branch) needs
@@ -796,28 +840,48 @@ export default function CampaignFlow({ campaignId }: { campaignId: string }) {
},
[adding, sequences.length, seqById, createSequence, saveBranches],
);
- // Drag from an IF block's side to empty -> new step + new if-branch.
- const addIfToNew = React.useCallback(
+ // Drag out -> new action step of the chosen type, connected unconditionally.
+ const dragOutAction = React.useCallback(
+ async (sourceId: string, type: SequenceActionType) => {
+ if (adding || sequences.length >= MAX_STEPS) return;
+ const src = seqById.get(sourceId);
+ setAdding(true);
+ try {
+ const created = (await createSequence.mutateAsync()) as Sequence;
+ await updateSequence(campaignId, created.id, { kind: "action", action: defaultActionFor(type) });
+ await saveBranches(sourceId, [
+ ...(src?.conditions?.branches ?? []),
+ { branch_id: newBranchId(), target_step_id: created.id, conditions: [] },
+ ]);
+ } catch {
+ toast.error("Couldn't add the action");
+ } finally {
+ setAdding(false);
+ }
+ },
+ [adding, sequences.length, seqById, createSequence, campaignId, saveBranches],
+ );
+ // Drag out -> a Condition router node (no-op step) you branch from. Chain
+ // these to build nested decision trees.
+ const dragOutCondition = React.useCallback(
async (sourceId: string) => {
if (adding || sequences.length >= MAX_STEPS) return;
const src = seqById.get(sourceId);
setAdding(true);
try {
const created = (await createSequence.mutateAsync()) as Sequence;
- const branch: SequenceBranch = {
- branch_id: newBranchId(),
- target_step_id: created.id,
- conditions: [{ field: "opened", operator: "within_days", value: 3 }],
- };
- await saveBranches(sourceId, [...(src?.conditions?.branches ?? []), branch]);
- openCondition(sourceId, branch.branch_id);
+ await updateSequence(campaignId, created.id, { kind: "wait", name: "Condition" });
+ await saveBranches(sourceId, [
+ ...(src?.conditions?.branches ?? []),
+ { branch_id: newBranchId(), target_step_id: created.id, conditions: [] },
+ ]);
} catch {
- toast.error("Couldn't add the step");
+ toast.error("Couldn't add the condition");
} finally {
setAdding(false);
}
},
- [adding, sequences.length, seqById, createSequence, saveBranches, openCondition],
+ [adding, sequences.length, seqById, createSequence, campaignId, saveBranches],
);
// "On reply" on a step: create a new action step + a reply branch that fires
// it the moment the contact replies, then open the branch so you can pick the
@@ -943,6 +1007,20 @@ export default function CampaignFlow({ campaignId }: { campaignId: string }) {
const allNodes: Node[] = sequences.map((s, i) => {
const branches = s.conditions?.branches ?? [];
const isAction = s.kind !== "email";
+ if (s.kind === "wait") {
+ // Pure router: a Condition node that only branches.
+ return {
+ id: s.id,
+ type: "condition",
+ position: { x: 0, y: 0 },
+ data: {
+ label: s.name?.trim() || "Condition",
+ endsHere: branches.length === 0,
+ orphan: !reachable.has(s.id),
+ onDelete: () => deleteStepRef.current(s.id),
+ } satisfies ConditionNodeData,
+ };
+ }
if (isAction) {
const at = s.action?.type ?? "add_tag";
const fallback = ACTION_META[at]?.label ?? "Action";
@@ -1270,9 +1348,13 @@ export default function CampaignFlow({ campaignId }: { campaignId: string }) {
preventScrolling={false}
minZoom={0.2}
maxZoom={1.75}
- onConnectEnd={(_, state) => {
+ onConnectEnd={(event, state) => {
const from = state.fromNode;
if (!from || state.toNode) return;
+ if (!canEditFlow) {
+ showPermissionDenied("MANAGE_SEQUENCES");
+ return;
+ }
const handle = state.fromHandle?.id;
if (isIfId(from.id)) {
const m = ifMetaRef.current[from.id];
@@ -1281,10 +1363,13 @@ export default function CampaignFlow({ campaignId }: { campaignId: string }) {
// step reached unconditionally ("always / just go there").
if (handle === "out") retargetToNew(m.sourceId, m.branchId);
else dragOutStep(m.sourceId);
- } else if (handle === "if") {
- addIfToNew(from.id);
} else {
- dragOutStep(from.id);
+ // Open the create menu at the drop point: email / action / condition.
+ const pt =
+ "changedTouches" in event && event.changedTouches.length
+ ? event.changedTouches[0]
+ : (event as MouseEvent);
+ setDragCreate({ x: pt.clientX, y: pt.clientY, sourceId: from.id });
}
}}
onReconnect={(oldEdge, conn) => {
@@ -1360,12 +1445,27 @@ export default function CampaignFlow({ campaignId }: { campaignId: string }) {
canvas (touch users remove edges via the editor's Disconnect). */}
- step: bottom dot = go there, right (amber) dot = add an “if” · IF block: right dot = then, bottom (gray) dot = else / just go there · click a line then press Delete to remove it · no match = stop
+ drag a node’s bottom dot onto another node to connect, or onto empty space to pick what to add (email, action, or a condition) · click a line to set its condition · add Condition nodes to branch, and chain them for nested trees · click a line then press Delete to remove it · no match = stop
+ >,
+ document.body,
+ );
+}
+
+function CreateRow({ icon, label, onClick }: { icon: React.ReactNode; label: string; onClick: () => void }) {
+ return (
+
+ );
+}
+
function AddNodeMenu({
onAddEmail,
onAddAction,
diff --git a/web/src/lib/api/models/app/campaigns/sequences/Sequence.ts b/web/src/lib/api/models/app/campaigns/sequences/Sequence.ts
index ab4c631b..70474181 100644
--- a/web/src/lib/api/models/app/campaigns/sequences/Sequence.ts
+++ b/web/src/lib/api/models/app/campaigns/sequences/Sequence.ts
@@ -18,10 +18,11 @@ export default interface Sequence {
// with this field replaces the step's branch set wholesale.
conditions?: SequenceConditions | null;
- // "email" (default — subject/body are sent) or "action" (a side effect named
- // by action.type — no email is sent). Step spacing is the per-step wait_after,
- // not a node kind.
- kind: "email" | "action";
+ // "email" (default — subject/body are sent), "action" (a side effect named
+ // by action.type — no email is sent), or "wait" (a no-op control node used
+ // as a Condition / pure router in the flow editor). Step spacing is the
+ // per-step wait_after, not a node kind.
+ kind: "email" | "action" | "wait";
// Typed config for action nodes; empty/absent for email nodes.
action?: SequenceAction | null;