-
if the contact
-
setField(e.target.value as BranchField)}>
- opened the email
- clicked a link
- replied
- is in a random split
-
+
+ {order >= 0 && condCount > 1 && (
+
+ When several conditions match, this is checked {order + 1} of {condCount}
+
+ onMove(-1)}
+ disabled={order === 0}
+ title="Check this earlier"
+ className="inline-flex size-5 items-center justify-center rounded text-slate-400 hover:bg-white hover:text-slate-700 disabled:opacity-30"
+ >
+
+
+ onMove(1)}
+ disabled={order === condCount - 1}
+ title="Check this later"
+ className="inline-flex size-5 items-center justify-center rounded text-slate-400 hover:bg-white hover:text-slate-700 disabled:opacity-30"
+ >
+
+
+
- {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)
-
- ) : (
-
- setOperator(e.target.value as BranchOperator)}>
- within
- ever (any time)
-
- {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 && (
+ save(null)}
+ className="text-[10.5px] font-medium text-rose-500 hover:underline"
+ >
+ set to stop
+
+ )}
-
- Drag the arrow's end onto another step to change where this goes.
-
+
+
+
Take this path
+
{
+ const f = e.target.value;
+ setField(f);
+ if (f === "random") setValue((v) => (v >= 1 && v <= 99 ? v : 50));
+ else if (f !== "always") setValue((v) => (v >= 1 && v <= 60 ? v : 3));
+ }}
+ >
+ always (right after the wait)
+ if opened the email
+ if clicked a link
+ if replied
+ if didn’t open
+ if didn’t click
+ if didn’t reply
+ random split
+
+
+
+ {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.
+
- onSave({
- branch_id: branch.branch_id,
- target_sequence_id: branch.target_sequence_id,
- conditions: [
- field === "random"
- ? { field: "random", operator: "chance", value: days }
- : operator === "within_days"
- ? { field, operator, value: days }
- : { field, operator },
- ],
- })
- }
- className="ml-auto h-7 px-3 rounded-md bg-sky-600 text-[12px] font-medium text-white hover:bg-sky-700"
+ onClick={() => save(branch.target_sequence_id)}
+ className="ml-auto h-7 rounded-md bg-sky-600 px-3 text-[12px] font-medium text-white hover:bg-sky-700"
>
Save
@@ -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"}
-
-
{
- const v = e.target.value;
- if (v === "default") onPick("default");
- else if (v === STOP_ID) onPick(null);
- else onPick(v);
- }}
- >
- continues to the next step
- stops here (end)
- {steps.map((s, i) =>
- s.id === sourceId ? null : (
-
- goes to Step {i + 1}
- {s.name ? ` · ${s.name}` : ""}
-
- ),
- )}
-
-
- 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}`}
-
- {
- e.stopPropagation();
- onDelete();
- }}
- className="ml-auto shrink-0 size-5 rounded inline-flex items-center justify-center text-slate-400 hover:text-rose-600 hover:bg-rose-50 transition-colors opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100"
- >
-
-
-
-
- {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 && (
-
-
- {creating ? (
-
- ) : (
-
- )}
- Add step
-
-
- 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",
};