mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-25 08:00:37 +00:00
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.
This commit is contained in:
@@ -48,8 +48,9 @@ function SequencesBuilder({ campaignId }: { campaignId: string }) {
|
||||
</div>
|
||||
<h2 className="text-[13px] font-medium text-slate-900">Build your flow</h2>
|
||||
<p className="mt-1 mb-4 max-w-xs text-center text-[11.5px] leading-relaxed text-slate-400">
|
||||
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.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,368 +0,0 @@
|
||||
// Step branching editor (lives under the Step composer). Lets you add
|
||||
// conditional routes off this step: "if the recipient opened / clicked /
|
||||
// replied (optionally within N days) -> go to step X, or stop the sequence".
|
||||
//
|
||||
// Branches are persisted as the step's `conditions: { branches: [...] }` via the
|
||||
// existing useUpdateSequence PATCH. The editor keeps a local draft and saves the
|
||||
// whole branch set at once (the PATCH replaces it wholesale).
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
GitBranchIcon,
|
||||
PlusIcon,
|
||||
Loader2Icon,
|
||||
Trash2Icon,
|
||||
XIcon,
|
||||
ArrowRightIcon,
|
||||
} 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 { Label, NumberInput } from "@/components/ui/field";
|
||||
import {
|
||||
PopoverMenu,
|
||||
PopoverMenuContent,
|
||||
PopoverMenuTrigger,
|
||||
PopoverMenuItem,
|
||||
SelectButton,
|
||||
} from "@/components/ui/popover-menu";
|
||||
import useUpdateSequence from "@/lib/api/hooks/app/campaigns/sequences/useUpdateSequence";
|
||||
import useSequences from "@/lib/api/hooks/app/campaigns/sequences/useSequences";
|
||||
import { useConfirm } from "@/hooks/context/confirm";
|
||||
import type { AppError } from "@/lib/api/client/normalizeError";
|
||||
import buildError from "@/lib/helper/buildError";
|
||||
|
||||
const FIELD_OPTIONS: { value: BranchField; label: string }[] = [
|
||||
{ value: "opened", label: "opened the email" },
|
||||
{ value: "clicked", label: "clicked a link" },
|
||||
{ value: "replied", label: "replied" },
|
||||
];
|
||||
|
||||
function newBranch(): SequenceBranch {
|
||||
return {
|
||||
branch_id:
|
||||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||
? crypto.randomUUID()
|
||||
: `branch-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
target_sequence_id: null,
|
||||
conditions: [{ field: "opened", operator: "within_days", value: 3 }],
|
||||
};
|
||||
}
|
||||
|
||||
function fieldLabel(f: BranchField): string {
|
||||
return FIELD_OPTIONS.find((o) => o.value === f)?.label ?? f;
|
||||
}
|
||||
|
||||
export default function BranchingSection({
|
||||
campaignId,
|
||||
sequence,
|
||||
}: {
|
||||
campaignId: string;
|
||||
sequence: Sequence;
|
||||
}) {
|
||||
const update = useUpdateSequence(campaignId, sequence.id);
|
||||
// The campaign's full ordered step list — used to render branch targets.
|
||||
// Reads the same cached query the composer already loaded (Suspense parent).
|
||||
const { data: sequences } = useSequences(campaignId);
|
||||
const confirm = useConfirm();
|
||||
|
||||
const [branches, setBranches] = React.useState<SequenceBranch[]>(
|
||||
() => sequence.conditions?.branches ?? [],
|
||||
);
|
||||
|
||||
// Re-seed when this step's record changes (switched step, saved elsewhere).
|
||||
React.useEffect(() => {
|
||||
setBranches(sequence.conditions?.branches ?? []);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sequence.id, sequence.updated_at]);
|
||||
|
||||
const dirty = React.useMemo(
|
||||
() => JSON.stringify(sequence.conditions?.branches ?? []) !== JSON.stringify(branches),
|
||||
[sequence.conditions, branches],
|
||||
);
|
||||
|
||||
// Steps other than this one are valid branch targets.
|
||||
const targets = sequences.filter((s) => s.id !== sequence.id);
|
||||
const targetIndex = (id: string) => sequences.findIndex((s) => s.id === id);
|
||||
|
||||
const patchBranch = (id: string, p: Partial<SequenceBranch>) =>
|
||||
setBranches((bs) => bs.map((b) => (b.branch_id === id ? { ...b, ...p } : b)));
|
||||
|
||||
const patchCondition = (
|
||||
branchId: string,
|
||||
idx: number,
|
||||
p: Partial<SequenceBranch["conditions"][number]>,
|
||||
) =>
|
||||
setBranches((bs) =>
|
||||
bs.map((b) =>
|
||||
b.branch_id === branchId
|
||||
? { ...b, conditions: b.conditions.map((c, i) => (i === idx ? { ...c, ...p } : c)) }
|
||||
: b,
|
||||
),
|
||||
);
|
||||
|
||||
const addCondition = (branchId: string) =>
|
||||
setBranches((bs) =>
|
||||
bs.map((b) =>
|
||||
b.branch_id === branchId
|
||||
? {
|
||||
...b,
|
||||
conditions: [
|
||||
...b.conditions,
|
||||
{ field: "clicked", operator: "always" } as SequenceBranch["conditions"][number],
|
||||
],
|
||||
}
|
||||
: b,
|
||||
),
|
||||
);
|
||||
|
||||
const removeCondition = (branchId: string, idx: number) =>
|
||||
setBranches((bs) =>
|
||||
bs.map((b) =>
|
||||
b.branch_id === branchId
|
||||
? { ...b, conditions: b.conditions.filter((_, i) => i !== idx) }
|
||||
: b,
|
||||
),
|
||||
);
|
||||
|
||||
const addBranch = () => setBranches((bs) => [...bs, newBranch()]);
|
||||
|
||||
const removeBranch = (id: string) =>
|
||||
setBranches((bs) => bs.filter((b) => b.branch_id !== id));
|
||||
|
||||
const save = async () => {
|
||||
if (!dirty || update.isPending) return;
|
||||
await toast.promise(update.mutateAsync({ conditions: { branches } }), {
|
||||
loading: "Saving branches…",
|
||||
success: "Branches saved.",
|
||||
error: (e: AppError) => buildError(e),
|
||||
});
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
if (!dirty) return;
|
||||
confirm.show("Discard unsaved branch changes for this step?", () => {
|
||||
setBranches(sequence.conditions?.branches ?? []);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-slate-200 bg-white">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-slate-200/70 px-3 py-2.5">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<GitBranchIcon className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">
|
||||
Branching
|
||||
</div>
|
||||
<p className="truncate text-[11px] text-slate-400">
|
||||
{branches.length === 0
|
||||
? "Route recipients to another step based on how they react."
|
||||
: `${branches.length} branch${branches.length === 1 ? "" : "es"}, checked top to bottom.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{dirty && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
disabled={update.isPending}
|
||||
className="h-7 px-2.5 rounded-md border border-slate-200 bg-white text-[12px] font-medium text-slate-700 transition-colors hover:border-slate-300 hover:text-slate-900 disabled:opacity-40"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={!dirty || update.isPending}
|
||||
className="h-7 px-3 rounded-md bg-sky-600 text-[12px] font-medium text-white transition-colors hover:bg-sky-700 inline-flex items-center gap-1.5 disabled:opacity-40"
|
||||
>
|
||||
{update.isPending && <Loader2Icon className="w-3 h-3 animate-spin" />}
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 p-3">
|
||||
{branches.length === 0 ? (
|
||||
<p className="text-[11.5px] text-slate-400">
|
||||
No branches. After this step, recipients continue to the next step in order.
|
||||
</p>
|
||||
) : (
|
||||
branches.map((b, bi) => (
|
||||
<div key={b.branch_id} className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">
|
||||
{bi === 0 ? "If" : "Else if"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeBranch(b.branch_id)}
|
||||
title="Remove branch"
|
||||
className="size-6 inline-flex items-center justify-center rounded-md text-slate-400 hover:text-rose-600 hover:bg-rose-50 transition-colors"
|
||||
>
|
||||
<Trash2Icon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{b.conditions.map((c, ci) => (
|
||||
<div key={ci} className="flex flex-wrap items-center gap-2">
|
||||
{ci > 0 && (
|
||||
<span className="text-[11px] font-medium text-slate-400">and</span>
|
||||
)}
|
||||
<span className="text-[12px] text-slate-500">recipient</span>
|
||||
|
||||
{/* field */}
|
||||
<PopoverMenu>
|
||||
<PopoverMenuTrigger asChild>
|
||||
<SelectButton label={fieldLabel(c.field)} />
|
||||
</PopoverMenuTrigger>
|
||||
<PopoverMenuContent minWidth={200}>
|
||||
{FIELD_OPTIONS.map((o) => (
|
||||
<PopoverMenuItem
|
||||
key={o.value}
|
||||
selected={o.value === c.field}
|
||||
onSelect={() =>
|
||||
patchCondition(b.branch_id, ci, { field: o.value })
|
||||
}
|
||||
>
|
||||
{o.label}
|
||||
</PopoverMenuItem>
|
||||
))}
|
||||
</PopoverMenuContent>
|
||||
</PopoverMenu>
|
||||
|
||||
{/* operator */}
|
||||
<PopoverMenu>
|
||||
<PopoverMenuTrigger asChild>
|
||||
<SelectButton
|
||||
label={c.operator === "within_days" ? "within" : "ever"}
|
||||
/>
|
||||
</PopoverMenuTrigger>
|
||||
<PopoverMenuContent minWidth={160}>
|
||||
<PopoverMenuItem
|
||||
selected={c.operator === "within_days"}
|
||||
onSelect={() =>
|
||||
patchCondition(b.branch_id, ci, {
|
||||
operator: "within_days" as BranchOperator,
|
||||
value: c.value ?? 3,
|
||||
})
|
||||
}
|
||||
>
|
||||
within N days
|
||||
</PopoverMenuItem>
|
||||
<PopoverMenuItem
|
||||
selected={c.operator === "always"}
|
||||
onSelect={() =>
|
||||
patchCondition(b.branch_id, ci, {
|
||||
operator: "always" as BranchOperator,
|
||||
})
|
||||
}
|
||||
>
|
||||
ever (any time)
|
||||
</PopoverMenuItem>
|
||||
</PopoverMenuContent>
|
||||
</PopoverMenu>
|
||||
|
||||
{c.operator === "within_days" && (
|
||||
<>
|
||||
<NumberInput
|
||||
value={c.value ?? 1}
|
||||
onChange={(v) => patchCondition(b.branch_id, ci, { value: v })}
|
||||
min={1}
|
||||
max={60}
|
||||
className="w-24"
|
||||
/>
|
||||
<span className="text-[12px] text-slate-500">days</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{b.conditions.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeCondition(b.branch_id, ci)}
|
||||
title="Remove condition"
|
||||
className="size-6 inline-flex items-center justify-center rounded-md text-slate-400 hover:text-rose-600 hover:bg-rose-50 transition-colors"
|
||||
>
|
||||
<XIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addCondition(b.branch_id)}
|
||||
className="inline-flex items-center gap-1 text-[11.5px] font-medium text-sky-600 hover:text-sky-700"
|
||||
>
|
||||
<PlusIcon className="w-3 h-3" />
|
||||
Add condition
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 border-t border-slate-200/70 pt-3">
|
||||
<ArrowRightIcon className="w-3.5 h-3.5 text-slate-400" />
|
||||
<Label className="mb-0">then</Label>
|
||||
<PopoverMenu>
|
||||
<PopoverMenuTrigger asChild>
|
||||
<SelectButton
|
||||
label={
|
||||
b.target_sequence_id === null
|
||||
? "stop the sequence"
|
||||
: (() => {
|
||||
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…";
|
||||
})()
|
||||
}
|
||||
/>
|
||||
</PopoverMenuTrigger>
|
||||
<PopoverMenuContent minWidth={240} className="max-h-60 overflow-y-auto">
|
||||
<PopoverMenuItem
|
||||
selected={b.target_sequence_id === null}
|
||||
onSelect={() => patchBranch(b.branch_id, { target_sequence_id: null })}
|
||||
>
|
||||
Stop the sequence
|
||||
</PopoverMenuItem>
|
||||
{targets.map((s) => {
|
||||
const idx = sequences.findIndex((x) => x.id === s.id);
|
||||
return (
|
||||
<PopoverMenuItem
|
||||
key={s.id}
|
||||
selected={b.target_sequence_id === s.id}
|
||||
onSelect={() =>
|
||||
patchBranch(b.branch_id, { target_sequence_id: s.id })
|
||||
}
|
||||
>
|
||||
{`Step ${idx + 1}${s.name ? ` · ${s.name}` : ""}`}
|
||||
</PopoverMenuItem>
|
||||
);
|
||||
})}
|
||||
</PopoverMenuContent>
|
||||
</PopoverMenu>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={addBranch}
|
||||
className="h-7 px-2.5 inline-flex items-center gap-1.5 rounded-md border border-slate-200 bg-white text-[12px] font-medium text-slate-700 hover:border-slate-300 hover:text-slate-900"
|
||||
>
|
||||
<PlusIcon className="w-3.5 h-3.5" />
|
||||
Add branch
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 (
|
||||
<div
|
||||
onClick={onClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
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",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 size-5 rounded-md inline-flex items-center justify-center font-mono text-[10.5px] tabular-nums font-medium",
|
||||
active ? "bg-sky-600 text-white" : "bg-slate-100 text-slate-500",
|
||||
)}
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate text-[12.5px] font-medium",
|
||||
active ? "text-sky-700" : "text-slate-700",
|
||||
)}
|
||||
>
|
||||
{name || `Step ${index + 1}`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Delete step"
|
||||
onClick={(e) => {
|
||||
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"
|
||||
>
|
||||
<Trash2Icon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1 truncate text-[11px]",
|
||||
active ? "text-sky-600/80" : "text-slate-400",
|
||||
)}
|
||||
>
|
||||
{subject || "No subject yet"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<number>(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 (
|
||||
<div className="flex items-stretch gap-2 pl-2.5">
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="w-px flex-1 bg-slate-200" />
|
||||
<span className="size-1.5 rounded-full bg-slate-300" />
|
||||
<span className="w-px flex-1 bg-slate-200" />
|
||||
</div>
|
||||
<div className="flex-1 py-1.5">
|
||||
<div className="inline-flex items-center gap-2 rounded-md border border-slate-200 bg-slate-50/60 px-2 py-1.5">
|
||||
<ClockIcon className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||
<span className="text-[11px] text-slate-500">Wait</span>
|
||||
<NumberInput
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
onCommit={commit}
|
||||
min={0}
|
||||
max={60}
|
||||
className="w-20"
|
||||
align="center"
|
||||
/>
|
||||
<span className="text-[11px] text-slate-500">days</span>
|
||||
{update.isPending ? (
|
||||
<Loader2Icon className="w-3 h-3 text-slate-300 animate-spin" />
|
||||
) : showSaved ? (
|
||||
<span className="inline-flex items-center gap-0.5 text-[10.5px] font-medium text-emerald-600">
|
||||
<CheckIcon className="w-3 h-3" />
|
||||
Saved
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-0">
|
||||
{sequences.map((seq, i) => (
|
||||
<React.Fragment key={seq.id}>
|
||||
{i === 0 ? (
|
||||
<div className="flex items-center gap-2 pl-2.5 pb-1.5 text-[11px] text-slate-400">
|
||||
<SendIcon className="w-3.5 h-3.5 text-slate-400" />
|
||||
Sends immediately
|
||||
</div>
|
||||
) : (
|
||||
<WaitConnector campaignId={campaignId} sequence={seq} />
|
||||
)}
|
||||
<SequenceBox
|
||||
index={i}
|
||||
name={seq.name}
|
||||
subject={seq.subject}
|
||||
active={seq.id === selectedId}
|
||||
onClick={() => onSelect(seq.id)}
|
||||
onDelete={() => requestDelete(seq, i)}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{sequences.length < MAX_STEPS && (
|
||||
<div className="pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreate}
|
||||
disabled={creating}
|
||||
className="flex w-full items-center justify-center gap-1.5 rounded-md border border-dashed border-slate-300 bg-white px-3 py-2 text-[12px] font-medium text-slate-500 transition-colors hover:border-sky-300 hover:text-sky-700 disabled:opacity-60"
|
||||
>
|
||||
{creating ? (
|
||||
<Loader2Icon className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<PlusIcon className="w-3.5 h-3.5" />
|
||||
)}
|
||||
Add step
|
||||
</button>
|
||||
<p className="mt-2 px-0.5 text-[10.5px] leading-relaxed text-slate-400">
|
||||
Up to {MAX_STEPS} steps. Follow-ups thread on the same subject line.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<BranchField, string> = {
|
||||
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",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user