From 929a779ae5a7e07f78e9e06affd09d77eccecd74 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Fri, 5 Jun 2026 09:11:24 +0200 Subject: [PATCH] feat: simplify campaign flow editor Move sequence editing fully into the explicit flow canvas, support negative branch conditions and connection waits, and remove the obsolete rail and standalone branch panel components. --- .../app/app/campaigns/[id]/sequences/page.tsx | 5 +- .../campaigns/sequences/BranchingSection.tsx | 368 ------ .../app/campaigns/sequences/CampaignFlow.tsx | 1022 ++++++++++------- .../app/campaigns/sequences/SequenceBox.tsx | 79 -- .../app/campaigns/sequences/StepRail.tsx | 181 --- .../app/campaigns/sequences/Branching.ts | 32 +- 6 files changed, 609 insertions(+), 1078 deletions(-) delete mode 100644 web/src/components/app/campaigns/sequences/BranchingSection.tsx delete mode 100644 web/src/components/app/campaigns/sequences/SequenceBox.tsx delete mode 100644 web/src/components/app/campaigns/sequences/StepRail.tsx diff --git a/web/src/app/app/campaigns/[id]/sequences/page.tsx b/web/src/app/app/campaigns/[id]/sequences/page.tsx index fc20b043..0199f635 100644 --- a/web/src/app/app/campaigns/[id]/sequences/page.tsx +++ b/web/src/app/app/campaigns/[id]/sequences/page.tsx @@ -48,8 +48,9 @@ function SequencesBuilder({ campaignId }: { campaignId: string }) {

Build your flow

- Add your first step, then connect steps to branch on opens, clicks, or replies. - The first step sends immediately; later steps wait and thread as follow-ups. + Add your first step, then drag from a step to branch on opens, clicks, or + replies. The first email sends immediately; later steps wait and thread as + follow-ups.

- )} - - - - -
- {branches.length === 0 ? ( -

- No branches. After this step, recipients continue to the next step in order. -

- ) : ( - branches.map((b, bi) => ( -
-
- - {bi === 0 ? "If" : "Else if"} - - -
- -
- {b.conditions.map((c, ci) => ( -
- {ci > 0 && ( - and - )} - recipient - - {/* field */} - - - - - - {FIELD_OPTIONS.map((o) => ( - - patchCondition(b.branch_id, ci, { field: o.value }) - } - > - {o.label} - - ))} - - - - {/* operator */} - - - - - - - patchCondition(b.branch_id, ci, { - operator: "within_days" as BranchOperator, - value: c.value ?? 3, - }) - } - > - within N days - - - patchCondition(b.branch_id, ci, { - operator: "always" as BranchOperator, - }) - } - > - ever (any time) - - - - - {c.operator === "within_days" && ( - <> - patchCondition(b.branch_id, ci, { value: v })} - min={1} - max={60} - className="w-24" - /> - days - - )} - - {b.conditions.length > 1 && ( - - )} -
- ))} - - -
- -
- - - - - { - const idx = targetIndex(b.target_sequence_id); - const t = sequences[idx]; - return idx >= 0 - ? `go to step ${idx + 1}${t?.name ? ` · ${t.name}` : ""}` - : "go to step…"; - })() - } - /> - - - patchBranch(b.branch_id, { target_sequence_id: null })} - > - Stop the sequence - - {targets.map((s) => { - const idx = sequences.findIndex((x) => x.id === s.id); - return ( - - patchBranch(b.branch_id, { target_sequence_id: s.id }) - } - > - {`Step ${idx + 1}${s.name ? ` · ${s.name}` : ""}`} - - ); - })} - - -
-
- )) - )} - - -
- - ); -} diff --git a/web/src/components/app/campaigns/sequences/CampaignFlow.tsx b/web/src/components/app/campaigns/sequences/CampaignFlow.tsx index 6d9e8525..760dc92a 100644 --- a/web/src/components/app/campaigns/sequences/CampaignFlow.tsx +++ b/web/src/components/app/campaigns/sequences/CampaignFlow.tsx @@ -1,15 +1,31 @@ -// Visual flow canvas for a campaign's steps (React Flow) — the single way to -// build a campaign's email steps and routing. +// Visual flow canvas for a campaign's steps (React Flow) — an explicit branching +// tree. Nothing connects automatically: a contact only moves where you've drawn +// a connection. // -// - Each step is a node (subject, delay). The default path (step N -> N+1) is a -// dashed "otherwise" arrow; branches are solid sky arrows to a target step or -// the STOP node, labeled with their condition. -// - ADD A STEP: drag from a step's bottom dot onto empty canvas, or click -// "+ Add step". ADD A BRANCH: drag from a step's dot onto another step, or -// click a step's "+ branch" button. EDIT a branch: click its arrow. EDIT a -// step's email: click the step. +// HOW IT WORKS +// - Each step is a box, identified by its name. The entry step is marked "Start". +// - Draw a connection from a step's bottom dot to another step (or empty canvas +// to make a new one). A connection with NO condition just means "go there +// after the wait" — you're never forced to pick a condition. +// - Add conditions to branch: click a connection and choose "if opened / clicked +// / replied / didn't… within N days" (or a random split). Draw several +// connections from one step for multiple branches; the first match wins. +// - Timing lives on the connections (wait N days before the step it points to). +// - Anything with no matching connection just ends — every path ends in STOP, +// automatically. A step with no outgoing connection shows "Ends here". import React from "react"; +import { + ChevronDownIcon, + ChevronUpIcon, + ClockIcon, + FlagIcon, + Loader2Icon, + MailIcon, + PlusIcon, + Trash2Icon, + XIcon, +} from "lucide-react"; import { ReactFlow, Background, @@ -27,41 +43,27 @@ import { import "@xyflow/react/dist/style.css"; import dagre from "@dagrejs/dagre"; import { useQueryClient } from "@tanstack/react-query"; -import { FlagIcon, GitBranchIcon, Loader2Icon, MailIcon, PlusIcon, Trash2Icon, XIcon } from "lucide-react"; import toast from "react-hot-toast"; import type Sequence from "@/lib/api/models/app/campaigns/sequences/Sequence"; -import type { - SequenceBranch, - BranchField, - BranchOperator, -} from "@/lib/api/models/app/campaigns/sequences/Branching"; +import type { SequenceBranch, BranchCondition, BranchField } from "@/lib/api/models/app/campaigns/sequences/Branching"; import { BRANCH_FIELD_LABELS } from "@/lib/api/models/app/campaigns/sequences/Branching"; import useSequences from "@/lib/api/hooks/app/campaigns/sequences/useSequences"; import useCreateSequence from "@/lib/api/hooks/app/campaigns/sequences/useCreateSequence"; +import useDeleteSequence from "@/lib/api/hooks/app/campaigns/sequences/useDeleteSequence"; import updateSequence from "@/lib/api/client/app/campaigns/sequences/updateSequence"; +import useCampaign from "@/lib/api/hooks/app/campaigns/useCampaign"; +import useUpdateCampaign from "@/lib/api/hooks/app/campaigns/useUpdateCampaign"; +import { useConfirm } from "@/hooks/context/confirm"; +import { NumberInput } from "@/components/ui/field"; +import type { AppError } from "@/lib/api/client/normalizeError"; +import buildError from "@/lib/helper/buildError"; import SequenceView from "./SequenceView"; const STOP_ID = "__stop__"; -const NODE_W = 230; -const NODE_H = 96; - -// Auto-lay-out the graph top-to-bottom so branches fan out visually instead of -// stacking in one column. Dagre gives node centers; React Flow wants top-left. -function layoutGraph(nodes: Node[], edges: Edge[]): Node[] { - const g = new dagre.graphlib.Graph(); - g.setDefaultEdgeLabel(() => ({})); - g.setGraph({ rankdir: "TB", nodesep: 70, ranksep: 80 }); - nodes.forEach((n) => { - const isStop = n.id === STOP_ID; - g.setNode(n.id, { width: isStop ? 90 : NODE_W, height: isStop ? 40 : NODE_H }); - }); - edges.forEach((e) => g.setEdge(e.source, e.target)); - dagre.layout(g); - return nodes.map((n) => { - const p = g.node(n.id); - return p ? { ...n, position: { x: p.x - p.width / 2, y: p.y - p.height / 2 } } : n; - }); -} +const NODE_W = 248; +const NODE_H = 92; +const MAX_STEPS = 50; +const SEQ_KEY = (id: string) => ["campaigns", id, "sequences"] as const; function newBranchId(): string { try { @@ -71,67 +73,109 @@ function newBranchId(): string { } } -function branchLabel(b: SequenceBranch): string { - if (!b.conditions || b.conditions.length === 0) return "any time"; - return b.conditions +const isCond = (b: SequenceBranch) => (b.conditions?.length ?? 0) > 0; +const stepName = (s: Sequence | undefined) => (s?.name?.trim() ? s.name : "Untitled step"); + +// Conditions are first-match; unconditional ("just go there") connections are the +// fallback, so keep them after the conditional ones. +function ordered(branches: SequenceBranch[]): SequenceBranch[] { + return [...branches.filter(isCond), ...branches.filter((b) => !isCond(b))]; +} + +function layoutGraph(nodes: Node[], edges: Edge[]): Node[] { + const g = new dagre.graphlib.Graph(); + g.setDefaultEdgeLabel(() => ({})); + // Generous spacing so step cards fan out as a clear tree and the little + // label cards on the connections never sit on top of each other. + g.setGraph({ rankdir: "TB", nodesep: 90, ranksep: 130, marginx: 20, marginy: 20, edgesep: 40 }); + nodes.forEach((n) => { + const stop = n.id === STOP_ID; + g.setNode(n.id, { width: stop ? 96 : NODE_W, height: stop ? 40 : NODE_H }); + }); + edges.forEach((e) => { + const text = typeof e.label === "string" ? e.label : ""; + // Give dagre the label card's footprint so it reserves room for it and + // never lets two condition cards land on top of each other. + g.setEdge( + e.source, + e.target, + text ? { width: Math.min(260, text.length * 6 + 24), height: 28, labelpos: "c" } : {}, + ); + }); + dagre.layout(g); + return nodes.map((n) => { + const p = g.node(n.id); + return p ? { ...n, position: { x: p.x - p.width / 2, y: p.y - p.height / 2 } } : n; + }); +} + +function conditionText(b: SequenceBranch): string { + return (b.conditions ?? []) .map((c) => { if (c.field === "random") return `${c.value ?? 50}% random`; const f = BRANCH_FIELD_LABELS[c.field] ?? c.field; - return c.operator === "within_days" ? `${f} within ${c.value ?? 3}d` : f; + return `${f} within ${c.value ?? 3}d`; }) .join(" + "); } -// ── Custom nodes ────────────────────────────────────────────────────────── +// ── Custom nodes ──────────────────────────────────────────────────────────── type StepNodeData = { label: string; subtitle: string; - wait: string; - index: number; - branchCount: number; - onAddBranch: () => void; + isStart: boolean; + endsHere: boolean; + onDelete: () => void; }; function StepNode({ data, selected }: NodeProps) { const d = data as StepNodeData; return (
- +
- - - Step {d.index + 1} - - {d.wait} + + {d.isStart && ( + + Start + + )} + +
-
{d.label || `Step ${d.index + 1}`}
-
{d.subtitle || "No subject"}
+
{d.label || "Untitled step"}
+
{d.subtitle || "No subject yet"}
- - + {d.endsHere && ( +
+ + Ends here +
+ )} +
); } function StopNode() { return ( -
- +
+ Stop
@@ -143,17 +187,17 @@ const nodeTypes = { step: StepNode, stop: StopNode }; export default function CampaignFlow({ campaignId }: { campaignId: string }) { const { data: sequences } = useSequences(campaignId); const createSequence = useCreateSequence(campaignId); + const deleteSequence = useDeleteSequence(campaignId); + const { data: campaign } = useCampaign(campaignId); + const updateCampaign = useUpdateCampaign(campaignId); + const confirm = useConfirm(); const qc = useQueryClient(); const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [selectedEdge, setSelectedEdge] = React.useState<{ sourceId: string; branchId: string } | null>(null); - const [selectedElse, setSelectedElse] = React.useState(null); const [editStepId, setEditStepId] = React.useState(null); const [adding, setAdding] = React.useState(false); - // Topology fingerprint of the last render; when it changes (a step or branch - // was added/removed) we re-run the auto-layout instead of keeping stale - // positions, so new branches fan out immediately without hitting "Tidy up". const structureSig = React.useRef(""); const seqById = React.useMemo(() => { @@ -162,175 +206,234 @@ export default function CampaignFlow({ campaignId }: { campaignId: string }) { return m; }, [sequences]); - const saveBranches = React.useCallback( - async (sourceId: string, branches: SequenceBranch[]) => { - try { - await updateSequence(campaignId, sourceId, { conditions: { branches } }); - qc.invalidateQueries({ queryKey: ["campaigns", campaignId, "sequences"] }); - } catch { - toast.error("Couldn't save the branch"); - } - }, - [campaignId, qc], + const invalidate = React.useCallback( + () => qc.invalidateQueries({ queryKey: SEQ_KEY(campaignId) }), + [qc, campaignId], ); - // Set a step's "else" (fallback) path. `"default"` removes any explicit - // catch-all so the step falls through to the linear next step; a step id or - // null (Stop) writes a single empty-conditions branch kept LAST in the array - // so it is evaluated after every conditional branch. - const setElse = React.useCallback( - (sourceId: string, target: string | null | "default") => { + // All steps reachable downstream of a step — i.e. everything "inside" a branch. + const reachableFrom = React.useCallback( + (rootId: string) => { + const set = new Set([rootId]); + const queue = [rootId]; + while (queue.length) { + const id = queue.shift()!; + for (const b of seqById.get(id)?.conditions?.branches ?? []) { + const t = b.target_sequence_id; + if (t && !set.has(t)) { + set.add(t); + queue.push(t); + } + } + } + return set; + }, + [seqById], + ); + + // Only ever one editor open at a time. + const openCondition = React.useCallback((sourceId: string, branchId: string) => { + setSelectedEdge({ sourceId, branchId }); + setEditStepId(null); + }, []); + const openEditStep = React.useCallback((id: string) => { + setEditStepId(id); + setSelectedEdge(null); + }, []); + + const saveBranches = React.useCallback( + async (sourceId: string, branches: SequenceBranch[]) => { + const b = ordered(branches); + qc.setQueryData(SEQ_KEY(campaignId), (old) => + old?.map((s) => (s.id === sourceId ? { ...s, conditions: { branches: b } } : s)), + ); + try { + await updateSequence(campaignId, sourceId, { conditions: { branches: b } }); + } catch { + toast.error("Couldn't save the connection"); + } finally { + invalidate(); + } + }, + [campaignId, qc, invalidate], + ); + + // Timing lives on connections: stored as the TARGET step's wait_after ("days + // before this step"), edited from the arrow leading to it. + const saveWait = React.useCallback( + async (targetId: string, days: number) => { + const d = Math.max(0, Math.round(days)); + qc.setQueryData(SEQ_KEY(campaignId), (old) => + old?.map((s) => (s.id === targetId ? { ...s, wait_after: d } : s)), + ); + try { + await updateSequence(campaignId, targetId, { wait_after: d }); + } catch { + toast.error("Couldn't save the wait"); + } finally { + invalidate(); + } + }, + [campaignId, qc, invalidate], + ); + + // Reorder a conditional connection among its siblings — i.e. change the + // if / else-if priority (which condition is checked first). + const moveBranch = React.useCallback( + (sourceId: string, branchId: string, dir: -1 | 1) => { const src = seqById.get(sourceId); if (!src) return; - const rest = (src.conditions?.branches ?? []).filter((b) => (b.conditions?.length ?? 0) > 0); - if (target !== "default") { - rest.push({ branch_id: newBranchId(), target_sequence_id: target, conditions: [] }); - } - saveBranches(sourceId, rest); + const all = src.conditions?.branches ?? []; + const conds = all.filter(isCond); + const i = conds.findIndex((b) => b.branch_id === branchId); + const j = i + dir; + if (i < 0 || j < 0 || j >= conds.length) return; + const next = [...conds]; + [next[i], next[j]] = [next[j], next[i]]; + saveBranches(sourceId, [...next, ...all.filter((b) => !isCond(b))]); }, [seqById, saveBranches], ); + // Add step drops a STANDALONE step (its own node) — nothing auto-connects. + // Connect it by dragging from another step, or drag from this one to extend. + // The first step is the entry (where new contacts start). const addStep = React.useCallback(async () => { - if (adding) return; + if (adding || sequences.length >= MAX_STEPS) return; setAdding(true); try { - await createSequence.mutateAsync(); - toast.success("Step added"); - } catch { - toast.error("Couldn't add the step"); + await toast.promise(createSequence.mutateAsync(), { + loading: "Adding step…", + success: "Step added — drag a step's dot to connect it.", + error: (err: AppError) => buildError(err), + }); } finally { setAdding(false); } - }, [adding, createSequence]); + }, [adding, sequences.length, createSequence]); - // Add a branch from a step with a sensible default (opened within 3 days -> - // the next step, or stop if none), then open the editor on it. - const addBranch = React.useCallback( - (sourceId: string) => { - const idx = sequences.findIndex((s) => s.id === sourceId); - const defaultTarget = - sequences[idx + 1]?.id ?? sequences.find((s) => s.id !== sourceId)?.id ?? null; - const branch: SequenceBranch = { - branch_id: newBranchId(), - target_sequence_id: defaultTarget, - conditions: [{ field: "opened", operator: "within_days", value: 3 }], - }; - const src = seqById.get(sourceId); - saveBranches(sourceId, [...(src?.conditions?.branches ?? []), branch]); - setSelectedEdge({ sourceId, branchId: branch.branch_id }); - }, - [sequences, seqById, saveBranches], - ); - - // Drag a step's dot onto empty canvas -> add a new step coming off that step. - // The first/only path out of the last step is the plain unconditional "next" - // (no branch); any additional path becomes a conditional branch, so only one - // ever runs per contact. - const handleDragOut = React.useCallback( + // Drag from a step onto empty canvas -> new step connected (unconditionally) + // from THAT step. No forced condition; add one later if you want a branch. + const dragOut = React.useCallback( async (sourceId: string) => { - if (adding) return; + if (adding || sequences.length >= MAX_STEPS) return; const src = seqById.get(sourceId); - const idx = sequences.findIndex((s) => s.id === sourceId); - const isLast = idx === sequences.length - 1; - const hasBranches = (src?.conditions?.branches ?? []).length > 0; setAdding(true); try { const created = (await createSequence.mutateAsync()) as Sequence; - if (!(isLast && !hasBranches)) { - const branch: SequenceBranch = { - branch_id: newBranchId(), - target_sequence_id: created.id, - conditions: [{ field: "opened", operator: "within_days", value: 3 }], - }; - await saveBranches(sourceId, [...(src?.conditions?.branches ?? []), branch]); - setSelectedEdge({ sourceId, branchId: branch.branch_id }); - } - toast.success("Step added"); + await saveBranches(sourceId, [ + ...(src?.conditions?.branches ?? []), + { branch_id: newBranchId(), target_sequence_id: created.id, conditions: [] }, + ]); } catch { toast.error("Couldn't add the step"); } finally { setAdding(false); } }, - [adding, sequences, seqById, createSequence, saveBranches], + [adding, sequences.length, seqById, createSequence, saveBranches], ); - React.useEffect(() => { - const stepNodes: Node[] = sequences.map((s, i) => ({ - id: s.id, - type: "step", - position: { x: 0, y: 0 }, - data: { - label: s.name, - subtitle: s.subject, - wait: i === 0 ? "sends now" : `wait ${s.wait_after}d`, - index: i, - branchCount: (s.conditions?.branches ?? []).filter((b) => (b.conditions?.length ?? 0) > 0).length, - onAddBranch: () => addBranch(s.id), - }, - })); - const hasStop = sequences.some((s) => (s.conditions?.branches ?? []).some((b) => b.target_sequence_id === null)); - if (hasStop) { - stepNodes.push({ - id: STOP_ID, - type: "stop", - position: { x: 0, y: 0 }, - data: {}, + const deleteStep = React.useCallback( + (id: string) => { + const label = stepName(seqById.get(id)); + const referencing = sequences.filter( + (s) => s.id !== id && (s.conditions?.branches ?? []).some((b) => b.target_sequence_id === id), + ); + const extra = referencing.length + ? ` ${referencing.length} connection${referencing.length === 1 ? "" : "s"} into it will be removed too.` + : ""; + confirm.show(`Delete “${label}”? This can't be undone.${extra}`, async () => { + try { + await Promise.all( + referencing.map((s) => + updateSequence(campaignId, s.id, { + conditions: { + branches: (s.conditions?.branches ?? []).filter((b) => b.target_sequence_id !== id), + }, + }), + ), + ); + await deleteSequence.mutateAsync(id); + invalidate(); + setEditStepId((cur) => (cur === id ? null : cur)); + setSelectedEdge((cur) => (cur?.sourceId === id ? null : cur)); + toast.success("Step deleted"); + } catch { + toast.error("Couldn't delete the step"); + throw new Error("delete-failed"); + } }); - } + }, + [sequences, seqById, confirm, campaignId, deleteSequence, invalidate], + ); + + const deleteRef = React.useRef(deleteStep); + React.useEffect(() => { + deleteRef.current = deleteStep; + }, [deleteStep]); + + React.useEffect(() => { + const stepNodes: Node[] = sequences.map((s, i) => { + const branches = s.conditions?.branches ?? []; + return { + id: s.id, + type: "step", + position: { x: 0, y: 0 }, + data: { + label: stepName(s), + subtitle: s.subject, + isStart: i === 0, + endsHere: branches.length === 0, + onDelete: () => deleteRef.current(s.id), + } satisfies StepNodeData, + }; + }); + + const anyStop = sequences.some((s) => (s.conditions?.branches ?? []).some((b) => b.target_sequence_id === null)); + if (anyStop) stepNodes.push({ id: STOP_ID, type: "stop", position: { x: 0, y: 0 }, data: {} }); + + const waitTag = (targetId: string | null) => { + if (!targetId) return ""; + const w = seqById.get(targetId)?.wait_after ?? 0; + return w > 0 ? `wait ${w}d` : ""; + }; const flowEdges: Edge[] = []; - sequences.forEach((s, i) => { - const branches = s.conditions?.branches ?? []; - const conditional = branches.filter((b) => (b.conditions?.length ?? 0) > 0); - // A single empty-conditions branch is the explicit "else" (catch-all), - // kept last on save so it is evaluated after every conditional branch. - const catchAll = branches.find((b) => (b.conditions?.length ?? 0) === 0); - for (const b of conditional) { - const target = b.target_sequence_id ?? STOP_ID; + sequences.forEach((s) => { + const branches = ordered(s.conditions?.branches ?? []); + for (const b of branches) { + const cond = isCond(b); + const wt = waitTag(b.target_sequence_id); + // Each connection shows ONLY its own condition — no auto + // "if" / "else if" / "else" labels imposed by position. An + // unconditional connection just shows its wait (or nothing). + const label: string | undefined = cond + ? wt + ? `${conditionText(b)} · ${wt}` + : conditionText(b) + : wt || undefined; flowEdges.push({ id: `br-${s.id}-${b.branch_id}`, source: s.id, - target, - label: branchLabel(b), + target: b.target_sequence_id ?? STOP_ID, + label, reconnectable: true, - style: { stroke: "#0ea5e9", strokeWidth: 2 }, - labelStyle: { fill: "#0369a1", fontSize: 10, fontWeight: 600 }, - labelBgStyle: { fill: "#e0f2fe" }, - labelBgPadding: [4, 2], + style: cond ? { stroke: "#0ea5e9", strokeWidth: 2 } : { stroke: "#94a3b8" }, + labelStyle: cond + ? { fill: "#0369a1", fontSize: 10.5, fontWeight: 600 } + : { fill: "#475569", fontSize: 10.5, fontWeight: 500 }, + // A little card: filled, bordered, rounded, with room to breathe. + labelBgStyle: { + fill: "#ffffff", + stroke: cond ? "#bae6fd" : "#e2e8f0", + strokeWidth: 1, + }, + labelBgPadding: [6, 4] as [number, number], + labelBgBorderRadius: 6, data: { sourceId: s.id, branchId: b.branch_id }, }); } - // The "else" path: an explicit catch-all if set, otherwise the implicit - // linear next step. Clickable + reconnectable so a step can fall back to - // any step or to Stop — that is how a step becomes branch-only. - const next = sequences[i + 1]; - if (catchAll) { - flowEdges.push({ - id: `else-${s.id}`, - source: s.id, - target: catchAll.target_sequence_id ?? STOP_ID, - label: "else", - reconnectable: true, - style: { stroke: "#cbd5e1", strokeDasharray: "4 4" }, - labelStyle: { fill: "#94a3b8", fontSize: 10 }, - labelBgStyle: { fill: "#fff" }, - data: { sourceId: s.id, isElse: true, branchId: catchAll.branch_id }, - }); - } else if (next) { - flowEdges.push({ - id: `else-${s.id}`, - source: s.id, - target: next.id, - label: conditional.length > 0 ? "else" : "then", - reconnectable: true, - style: { stroke: "#cbd5e1", strokeDasharray: "4 4" }, - labelStyle: { fill: "#94a3b8", fontSize: 10 }, - labelBgStyle: { fill: "#fff" }, - data: { sourceId: s.id, isElse: true }, - }); - } }); const laid = layoutGraph(stepNodes, flowEdges); @@ -338,36 +441,62 @@ export default function CampaignFlow({ campaignId }: { campaignId: string }) { stepNodes.map((n) => n.id).sort().join(",") + "|" + flowEdges.map((e) => `${e.source}>${e.target}`).sort().join(","); - const structureChanged = sig !== structureSig.current; + const changed = sig !== structureSig.current; structureSig.current = sig; setNodes((cur) => { - // On a topology change (step/branch added or removed) re-flow from - // scratch so branches fan out; otherwise keep manual drag positions. - if (structureChanged) return laid; + if (changed) return laid; const pos = new Map(cur.map((n) => [n.id, n.position])); return laid.map((n) => (pos.has(n.id) ? { ...n, position: pos.get(n.id)! } : n)); }); setEdges(flowEdges); - }, [sequences, setNodes, setEdges, addBranch]); + }, [sequences, seqById, setNodes, setEdges]); + // Highlight the subtree of the selected connection (its "if" branch) or the + // selected step, dimming everything else so it's clear what's inside it. + React.useEffect(() => { + let root: string | null = null; + if (selectedEdge) { + const br = seqById + .get(selectedEdge.sourceId) + ?.conditions?.branches?.find((b) => b.branch_id === selectedEdge.branchId); + root = br?.target_sequence_id ?? null; + } else if (editStepId) { + root = editStepId; + } + const hl = root ? reachableFrom(root) : null; + setNodes((ns) => + ns.map((n) => ({ + ...n, + style: { ...n.style, opacity: hl && n.id !== STOP_ID && !hl.has(n.id) ? 0.3 : 1 }, + })), + ); + setEdges((es) => + es.map((e) => ({ + ...e, + style: { ...e.style, opacity: hl && !(hl.has(e.source) && hl.has(e.target)) ? 0.15 : 1 }, + })), + ); + }, [selectedEdge, editStepId, seqById, reachableFrom, setNodes, setEdges]); + + // Drag a node's dot onto another node (or Stop) -> new unconditional link. const onConnect = React.useCallback( (c: Connection) => { if (!c.source || !c.target || c.source === c.target) return; const src = seqById.get(c.source); if (!src) return; - const target = c.target === STOP_ID ? null : c.target; - const branch: SequenceBranch = { - branch_id: newBranchId(), - target_sequence_id: target, - conditions: [{ field: "opened", operator: "within_days", value: 3 }], - }; - saveBranches(c.source, [...(src.conditions?.branches ?? []), branch]); - setSelectedEdge({ sourceId: c.source, branchId: branch.branch_id }); + saveBranches(c.source, [ + ...(src.conditions?.branches ?? []), + { + branch_id: newBranchId(), + target_sequence_id: c.target === STOP_ID ? null : c.target, + conditions: [], + }, + ]); }, [seqById, saveBranches], ); - const selectedBranch: { source: Sequence; branch: SequenceBranch } | null = React.useMemo(() => { + const selected = React.useMemo(() => { if (!selectedEdge) return null; const src = seqById.get(selectedEdge.sourceId); const br = src?.conditions?.branches?.find((b) => b.branch_id === selectedEdge.branchId); @@ -376,9 +505,10 @@ export default function CampaignFlow({ campaignId }: { campaignId: string }) { const editStep = editStepId ? seqById.get(editStepId) : undefined; const editIndex = editStep ? sequences.findIndex((s) => s.id === editStep.id) : -1; + const atMax = sequences.length >= MAX_STEPS; return ( -
+
{ - // Dropped on empty canvas (no target node) -> add a step off the source. - if (state.fromNode && !state.toNode) handleDragOut(state.fromNode.id); + if (state.fromNode && !state.toNode) dragOut(state.fromNode.id); }} onReconnect={(oldEdge, conn) => { - // Dragged an arrow's end onto a different step -> retarget it. - const d = oldEdge.data as { sourceId?: string; branchId?: string; isElse?: boolean } | undefined; - if (!d?.sourceId || !conn.target) return; + const d = oldEdge.data as { sourceId?: string; branchId?: string } | undefined; + if (!d?.sourceId || !d?.branchId || !conn.target) return; const src = seqById.get(d.sourceId); if (!src) return; const newTarget = conn.target === STOP_ID ? null : conn.target; - if (d.isElse) { - // Dragging the dashed "else" arrow makes the fallback explicit. - setElse(d.sourceId, newTarget); - return; - } - if (!d.branchId) return; - const branches = (src.conditions?.branches ?? []).map((b) => - b.branch_id === d.branchId ? { ...b, target_sequence_id: newTarget } : b, + saveBranches( + d.sourceId, + (src.conditions?.branches ?? []).map((b) => + b.branch_id === d.branchId ? { ...b, target_sequence_id: newTarget } : b, + ), ); - saveBranches(d.sourceId, branches); }} nodeTypes={nodeTypes} onEdgeClick={(_, edge) => { - const d = edge.data as { sourceId?: string; branchId?: string; isElse?: boolean } | undefined; - if (!d?.sourceId) return; - if (d.isElse) setSelectedElse(d.sourceId); - else if (d.branchId) setSelectedEdge({ sourceId: d.sourceId, branchId: d.branchId }); + const d = edge.data as { sourceId?: string; branchId?: string } | undefined; + if (d?.sourceId && d?.branchId) openCondition(d.sourceId, d.branchId); }} onNodeClick={(_, node) => { - if (node.id !== STOP_ID) setEditStepId(node.id); + if (node.id !== STOP_ID) openEditStep(node.id); }} fitView proOptions={{ hideAttribution: true }} @@ -428,8 +550,8 @@ export default function CampaignFlow({ campaignId }: { campaignId: string }) { + { + qc.setQueryData(["campaigns", campaignId], (old: unknown) => + old ? { ...(old as object), stop_on_reply: next } : old, + ); + updateCampaign + .mutateAsync({ stop_on_reply: next }) + .catch((err) => toast.error(buildError(err as AppError))); + }} + />
-
+
- else — click to send elsewhere or end + condition - branch — first match wins, only one runs + just go there - drag a step's bottom dot onto empty space or another step + drag a step's dot to connect · no match = stop
- {selectedBranch && ( - b.branch_id === selected.branch.branch_id)} + condCount={(selected.source.conditions?.branches ?? []).filter(isCond).length} + onMove={(dir) => moveBranch(selected.source.id, selected.branch.branch_id, dir)} + waitDays={seqById.get(selected.branch.target_sequence_id ?? "")?.wait_after ?? 0} onClose={() => setSelectedEdge(null)} + onSetWait={(days) => { + if (selected.branch.target_sequence_id) saveWait(selected.branch.target_sequence_id, days); + }} onSave={(updated) => { - const branches = (selectedBranch.source.conditions?.branches ?? []).map((b) => - b.branch_id === updated.branch_id ? updated : b, + saveBranches( + selected.source.id, + (selected.source.conditions?.branches ?? []).map((b) => + b.branch_id === updated.branch_id ? updated : b, + ), ); - saveBranches(selectedBranch.source.id, branches); setSelectedEdge(null); }} onDelete={() => { - const branches = (selectedBranch.source.conditions?.branches ?? []).filter( - (b) => b.branch_id !== selectedBranch.branch.branch_id, + saveBranches( + selected.source.id, + (selected.source.conditions?.branches ?? []).filter( + (b) => b.branch_id !== selected.branch.branch_id, + ), ); - saveBranches(selectedBranch.source.id, branches); setSelectedEdge(null); }} /> )} - {selectedElse && - (() => { - const src = seqById.get(selectedElse); - if (!src) return null; - const sIdx = sequences.findIndex((s) => s.id === src.id); - const branches = src.conditions?.branches ?? []; - const catchAll = branches.find((b) => (b.conditions?.length ?? 0) === 0); - const hasConditional = branches.some((b) => (b.conditions?.length ?? 0) > 0); - const current = catchAll ? catchAll.target_sequence_id ?? STOP_ID : "default"; - return ( - setSelectedElse(null)} - onPick={(target) => { - setElse(src.id, target); - setSelectedElse(null); - }} - /> - ); - })()} - {editStep && ( -
+
- Edit step - + Edit “{stepName(editStep)}” +
+ + +
@@ -526,11 +656,62 @@ export default function CampaignFlow({ campaignId }: { campaignId: string }) { ); } -// ── Branch editor (plain-English condition editor for a selected arrow) ────── -function BranchEditor({ +// ── Stop-on-reply toggle ──────────────────────────────────────────────────── +function StopOnReplyToggle({ on, onToggle }: { on: boolean; onToggle: (next: boolean) => void }) { + return ( +
+ Stop on reply + +
+ ); +} + +function WaitRow({ value, onCommit }: { value: number; onCommit: (v: number) => void }) { + const [draft, setDraft] = React.useState(value); + React.useEffect(() => setDraft(value), [value]); + return ( +
+ + wait + onCommit(Math.max(0, Math.round(v)))} + min={0} + max={60} + className="w-16" + align="center" + /> + days before it +
+ ); +} + +// ── Connection editor (optional condition + wait behind an arrow) ─────────── +function ConnectionEditor({ source, branch, steps, + order, + condCount, + onMove, + waitDays, + onSetWait, onClose, onSave, onDelete, @@ -538,115 +719,151 @@ function BranchEditor({ source: Sequence; branch: SequenceBranch; steps: Sequence[]; + order: number; // index among the step's conditional branches, -1 if unconditional + condCount: number; + onMove: (dir: -1 | 1) => void; + waitDays: number; + onSetWait: (days: number) => void; onClose: () => void; onSave: (b: SequenceBranch) => void; onDelete: () => void; }) { - const c0 = branch.conditions?.[0] ?? { field: "opened", operator: "within_days", value: 3 }; - const [field, setField] = React.useState(c0.field); - const [operator, setOperator] = React.useState(c0.operator); - const [days, setDays] = React.useState(c0.value ?? (c0.field === "random" ? 50 : 3)); + const c0 = branch.conditions?.[0]; + // "always" = no condition (just go there). Otherwise an engagement field. + const [field, setField] = React.useState(c0 ? c0.field : "always"); + const [value, setValue] = React.useState(c0?.value ?? (c0?.field === "random" ? 50 : 3)); + const sel = - "h-7 rounded-md border border-slate-200 bg-white px-2 text-[12px] text-slate-800 focus:border-sky-400 focus:outline-none"; - const sourceIdx = steps.findIndex((s) => s.id === source.id); - const targetIdx = steps.findIndex((s) => s.id === branch.target_sequence_id); - const targetLabel = - branch.target_sequence_id === null - ? "Stop the sequence" - : targetIdx >= 0 - ? `Step ${targetIdx + 1}${steps[targetIdx].name ? ` · ${steps[targetIdx].name}` : ""}` - : "—"; + "h-7 w-full rounded-md border border-slate-200 bg-white px-2 text-[12px] text-slate-800 focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100"; + const isAlways = field === "always"; + const isRandom = field === "random"; + const isNegative = field === "not_opened" || field === "not_clicked" || field === "not_replied"; + const target = steps.find((s) => s.id === branch.target_sequence_id); + const targetLabel = branch.target_sequence_id === null ? "Stop the sequence" : target ? `“${stepName(target)}”` : "—"; + + const buildConditions = (): BranchCondition[] => { + if (isAlways) return []; + if (isRandom) return [{ field: "random", operator: "chance", value }]; + return [{ field: field as BranchField, operator: "within_days", value }]; + }; + const save = (target_sequence_id: string | null) => + onSave({ branch_id: branch.branch_id, target_sequence_id, conditions: buildConditions() }); return ( -
+
- - After step {sourceIdx + 1} + + From “{stepName(source)}”
-
-
- if the contact - + + {order >= 0 && condCount > 1 && ( +
+ When several conditions match, this is checked {order + 1} of {condCount} + + + +
- {field === "random" ? ( -
- setDays(Math.max(1, Math.min(99, Number(e.target.value) || 1)))} - className={`${sel} w-16 text-center`} - /> - % of contacts (chosen at random) -
- ) : ( -
- - {operator === "within_days" && ( - <> - setDays(Math.max(1, Math.min(60, Number(e.target.value) || 1)))} - className={`${sel} w-16 text-center`} - /> - days - - )} -
- )} + )} + +
then go to {targetLabel} + {branch.target_sequence_id !== null && ( + + )}
-

- Drag the arrow's end onto another step to change where this goes. -

+ +
+

Take this path

+ +
+ + {isRandom && ( +
+ setValue(Math.max(1, Math.min(99, Math.round(v) || 1)))} min={1} max={99} className="w-16" align="center" /> + % of contacts (chosen at random) +
+ )} + {!isAlways && !isRandom && ( +
+ within + setValue(Math.max(1, Math.min(60, Math.round(v) || 1)))} min={1} max={60} className="w-16" align="center" /> + days +
+ )} + {isNegative && ( +

+ We keep checking until {value} day{value === 1 ? "" : "s"} pass, then take this path if it still hasn’t happened. +

+ )} + + {branch.target_sequence_id !== null && } +

Drag the arrow’s end onto another step to change where it goes.

+
@@ -654,74 +871,3 @@ function BranchEditor({
); } - -// ── Else editor (the fallback path when no branch matches) ─────────────────── -// Picks where the contact goes when none of a step's branches fire: continue to -// the next step (default), end the sequence, or jump to a specific step. Setting -// it to "end" or another step is what turns a step branch-only. -function ElseEditor({ - sourceId, - sourceIndex, - hasConditional, - current, - steps, - onClose, - onPick, -}: { - sourceId: string; - sourceIndex: number; - hasConditional: boolean; - current: string; // "default" | STOP_ID | stepId - steps: Sequence[]; - onClose: () => void; - onPick: (target: string | null | "default") => void; -}) { - const sel = - "h-7 w-full rounded-md border border-slate-200 bg-white px-2 text-[12px] text-slate-800 focus:border-sky-400 focus:outline-none"; - return ( -
-
- - After step {sourceIndex + 1} · else - - -
-

- {hasConditional - ? "When none of the branches above match, the contact" - : "After this step, the contact"} -

- -

- Set this to “stops here” (or another step) to make this step branch-only. You can also drag the dashed - arrow’s end onto another step. -

-
- ); -} diff --git a/web/src/components/app/campaigns/sequences/SequenceBox.tsx b/web/src/components/app/campaigns/sequences/SequenceBox.tsx deleted file mode 100644 index ad458de4..00000000 --- a/web/src/components/app/campaigns/sequences/SequenceBox.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { Trash2Icon } from "lucide-react"; -import { cn } from "@/lib/utils"; - -// One selectable step in the sequence stepper. Renders "Step N", the step's -// display name, and a subject preview. Active rows highlight sky; the delete -// affordance stays touch-reachable (visible on mobile, hover-revealed on md+). -export default function SequenceBox({ - index, - name, - subject, - active, - onClick, - onDelete, -}: { - index: number; - name: string; - subject: string; - active: boolean; - onClick: () => void; - onDelete: () => void; -}) { - return ( -
{ - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onClick(); - } - }} - className={cn( - "group relative w-full select-none cursor-pointer rounded-md border px-3 py-2.5 transition-colors", - active - ? "border-sky-300 bg-sky-50 ring-2 ring-sky-100" - : "border-slate-200 bg-white hover:border-slate-300 hover:bg-slate-50", - )} - > -
- - {index + 1} - - - {name || `Step ${index + 1}`} - - -
-

- {subject || "No subject yet"} -

-
- ); -} diff --git a/web/src/components/app/campaigns/sequences/StepRail.tsx b/web/src/components/app/campaigns/sequences/StepRail.tsx deleted file mode 100644 index bf70151a..00000000 --- a/web/src/components/app/campaigns/sequences/StepRail.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import React from "react"; -import { CheckIcon, ClockIcon, Loader2Icon, PlusIcon, SendIcon } from "lucide-react"; -import toast from "react-hot-toast"; -import { NumberInput } from "@/components/ui/field"; -import SequenceBox from "./SequenceBox"; -import type Sequence from "@/lib/api/models/app/campaigns/sequences/Sequence"; -import useUpdateSequence from "@/lib/api/hooks/app/campaigns/sequences/useUpdateSequence"; -import useDeleteSequence from "@/lib/api/hooks/app/campaigns/sequences/useDeleteSequence"; -import type { AppError } from "@/lib/api/client/normalizeError"; -import buildError from "@/lib/helper/buildError"; -import { useConfirm } from "@/hooks/context/confirm"; - -const MAX_STEPS = 5; - -// WaitConnector — the vertical line between two steps. Step 1 has no preceding -// connector (it sends immediately); every later step is preceded by a "Wait N -// days" control whose NumberInput persists wait_after on the step it gates. -function WaitConnector({ - campaignId, - sequence, -}: { - campaignId: string; - sequence: Sequence; -}) { - const update = useUpdateSequence(campaignId, sequence.id); - // `draft` is the live edited value; it only persists on a commit point - // (blur / Enter / stepper) — never on every keystroke — so an in-flight - // save can't snap the field back mid-typing. - const [draft, setDraft] = React.useState(sequence.wait_after); - const [savedAt, setSavedAt] = React.useState(0); - - // Keep the local draft in lockstep with the canonical value when the cache - // updates (e.g. after a save elsewhere). - React.useEffect(() => { - setDraft(sequence.wait_after); - }, [sequence.wait_after]); - - const commit = (v: number) => { - const next = Math.max(0, Math.round(v)); - setDraft(next); - if (next === sequence.wait_after) return; // no change → nothing to save - update.mutate( - { wait_after: next }, - { - onSuccess: () => setSavedAt((n) => n + 1), - onError: (err) => { - setDraft(sequence.wait_after); - toast.error(buildError(err as unknown as AppError)); - }, - }, - ); - }; - - // Briefly flash a "saved" tick after a successful commit. - const [showSaved, setShowSaved] = React.useState(false); - React.useEffect(() => { - if (savedAt === 0) return; - setShowSaved(true); - const t = setTimeout(() => setShowSaved(false), 1400); - return () => clearTimeout(t); - }, [savedAt]); - - return ( -
-
- - - -
-
-
- - Wait - - days - {update.isPending ? ( - - ) : showSaved ? ( - - - Saved - - ) : null} -
-
-
- ); -} - -export default function StepRail({ - campaignId, - sequences, - selectedId, - onSelect, - onCreate, - creating, -}: { - campaignId: string; - sequences: Sequence[]; - selectedId: string; - onSelect: (id: string) => void; - onCreate: () => void; - creating: boolean; -}) { - const confirm = useConfirm(); - const deleteSequence = useDeleteSequence(campaignId); - - function requestDelete(seq: Sequence, index: number) { - const name = seq.name || `Step ${index + 1}`; - confirm.show( - `Delete step ${index + 1}? "${name}" and its content will be removed from this campaign. This can't be undone.`, - async () => { - try { - await deleteSequence.mutateAsync(seq.id); - toast.success("Step removed."); - if (selectedId === seq.id) { - const remaining = sequences.filter((s) => s.id !== seq.id); - onSelect(remaining[0]?.id ?? ""); - } - } catch (err) { - toast.error(buildError(err as AppError)); - } - }, - ); - } - - return ( -
- {sequences.map((seq, i) => ( - - {i === 0 ? ( -
- - Sends immediately -
- ) : ( - - )} - onSelect(seq.id)} - onDelete={() => requestDelete(seq, i)} - /> -
- ))} - - {sequences.length < MAX_STEPS && ( -
- -

- Up to {MAX_STEPS} steps. Follow-ups thread on the same subject line. -

-
- )} - -
- ); -} diff --git a/web/src/lib/api/models/app/campaigns/sequences/Branching.ts b/web/src/lib/api/models/app/campaigns/sequences/Branching.ts index 0fd7bc64..e5f196d3 100644 --- a/web/src/lib/api/models/app/campaigns/sequences/Branching.ts +++ b/web/src/lib/api/models/app/campaigns/sequences/Branching.ts @@ -1,28 +1,37 @@ -// Step branching — conditional routing between steps. Each branch fires when its -// conditions match (recipient opened / clicked / replied, optionally within N -// days) and routes to a target step, or stops the sequence when the target is -// null. +// Step branching — the routing drawn on the flow canvas. Each branch fires when +// its condition matches (the recipient opened / clicked / replied, or did NOT, +// optionally within N days; or a random split) and routes the contact to a +// target step, or stops the sequence when the target is null. Branches are +// first-match in order; when none match the contact stops unless another +// unconditional connection from the step matches. // // Shipped on the sequence PATCH body as `conditions: { branches: [...] }` // (PATCH /campaigns/:id/sequences/:seqId), so it rides the existing // useUpdateSequence mutation. -export type BranchField = "opened" | "clicked" | "replied" | "random"; -export type BranchOperator = "within_days" | "always" | "chance"; +export type BranchField = + | "opened" + | "clicked" + | "replied" + | "not_opened" + | "not_clicked" + | "not_replied" + | "random"; + +export type BranchOperator = "within_days" | "ever" | "chance"; export interface BranchCondition { field: BranchField; operator: BranchOperator; - // For `within_days`: the number of days the event must fall within. For - // `random`/`chance`: the percentage (1-99) of contacts that take this branch - // (deterministic per contact). Omitted/ignored for `always`. + // Days for `within_days`; percent (1-99) for `random`/`chance`. Omitted for `ever`. value?: number; } export interface SequenceBranch { branch_id: string; - // null = stop the sequence when this branch matches. + // The step to route to when this branch matches. null = stop the sequence. target_sequence_id: string | null; + // ANDed conditions. An empty list is the catch-all "else" branch. conditions: BranchCondition[]; } @@ -35,5 +44,8 @@ export const BRANCH_FIELD_LABELS: Record = { opened: "opened the email", clicked: "clicked a link", replied: "replied", + not_opened: "didn’t open", + not_clicked: "didn’t click", + not_replied: "didn’t reply", random: "random split", };