diff --git a/web/src/components/app/campaigns/sequences/CampaignFlow.tsx b/web/src/components/app/campaigns/sequences/CampaignFlow.tsx index 29e8d66b..f9b8d242 100644 --- a/web/src/components/app/campaigns/sequences/CampaignFlow.tsx +++ b/web/src/components/app/campaigns/sequences/CampaignFlow.tsx @@ -60,7 +60,7 @@ import { useQueryClient } from "@tanstack/react-query"; import toast from "react-hot-toast"; import type Sequence from "@/lib/api/models/app/campaigns/sequences/Sequence"; import type { SequenceBranch, BranchCondition, BranchField } from "@/lib/api/models/app/campaigns/sequences/Branching"; -import { BRANCH_FIELD_LABELS, isReplyBranchField } from "@/lib/api/models/app/campaigns/sequences/Branching"; +import { BRANCH_FIELD_LABELS, isReplyBranchField, isInstantCapableField } 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"; @@ -106,6 +106,28 @@ function newBranchId(): string { const isCond = (b: SequenceBranch) => (b.conditions?.length ?? 0) > 0; const stepName = (s: Sequence | undefined) => (s?.name?.trim() ? s.name : "Untitled step"); +// "Positive" reply fields are the ones that route a contact INTO a reply flow +// (a human reply, or a classified reply intent). Mirrors the backend's +// branchHasPositiveReplyCondition. not_replied is excluded — that is the cold +// sequence's "didn't reply" continuation, not reply handling. Used to decide +// whether a campaign has any reply handling at all (the stop-on-reply warning). +const POSITIVE_REPLY_FIELDS: BranchField[] = [ + "replied", + "reply_positive", + "reply_negative", + "reply_neutral", + "reply_automated", +]; +const isPositiveReplyField = (f: BranchField) => POSITIVE_REPLY_FIELDS.includes(f); + +// A branch is "instant" when its condition is an instant-capable signal +// (reply intent, opened, or clicked) and the per-branch toggle isn't opted out. +// Such a branch's action chain fires the moment the event lands (reply recorded, +// or open / click tracked), so the target step's wait_after is irrelevant. +function isInstantBranch(b: SequenceBranch): boolean { + return (b.conditions ?? []).some((c) => isInstantCapableField(c.field)) && b.instant !== false; +} + // Conditions first-match; unconditional ("just go there") paths are the fallback. function ordered(branches: SequenceBranch[]): SequenceBranch[] { return [...branches.filter(isCond), ...branches.filter((b) => !isCond(b))]; @@ -299,15 +321,16 @@ type IfNodeData = { label: string; instant: boolean; onDelete: () => void }; function IfNode({ data, selected }: NodeProps) { const d = data as IfNodeData; - // Reply-class branches fire the moment the contact replies, not at the next - // scheduled step. A violet "instant" badge distinguishes them from the - // engagement (opened / clicked / within-N-days) branches that are checked at - // the next step boundary. The whole node carries a tooltip explaining it. + // Instant-capable branches (reply intent, opened, clicked) fire the moment + // the event lands, not at the next scheduled step. A violet "instant" badge + // distinguishes them from the branches checked at the next step boundary + // (negative "didn't" signals, within-N-days windows, random / always). The + // whole node carries a tooltip explaining it. return (
- {field === "reply_automated" - ? "Routes when the contact's reply is an auto-reply or out-of-office bounce, not a real human reply. Pair this with action steps (create deal, move stage, notify) to react." - : "Routes when the contact's reply is classified this way. Chain action steps after it, for example create deal then move stage then notify."} -
-- A reply is matched to the email it answers. If a contact replies to an earlier email after later steps already sent, it still triggers that email's reply path (and pauses the sequence when stop-on-reply is on). -
+ {isReply ? ( + <> ++ {field === "reply_automated" + ? "Routes when the contact's reply is an auto-reply or out-of-office bounce, not a real human reply. Pair this with action steps (create deal, move stage, notify) to react." + : "Routes when the contact's reply is classified this way. Chain action steps after it, for example create deal then move stage then notify."} +
++ A reply triggers the reply path of the specific email it answers (matched by the reply's threading headers): the earlier email, not the latest step, and never both. It falls back to the latest step only when the reply can't be threaded. With stop-on-reply on, the sequence also pauses. +
+ > + ) : ( ++ {instant + ? `Runs the moment they ${instantVerb}, as long as they ${instantVerb} within the ${value}-day window above. If they don't ${instantVerb} in that time, this path never runs. Chain action steps after it (create deal, move stage, notify).` + : `Waits up to ${value} day${value === 1 ? "" : "s"} for them to ${instantVerb}, then checks at the next step. If they don't ${instantVerb} in that window, this path never runs.`} +
+ )}