mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-29 08:12:40 +00:00
feat: fix campaign restart dead end (issue #340): resolve preflight tracking senders through the same explicit/tags/all pool the scheduler uses so a connected mailbox is never reported missing, fetch the launch dialog's step count from the steps endpoint instead of a field the campaign API never returns, turn on Keep running for new leads when a form or an automation feeds a campaign (migration 000131 backfills existing ones) and when a member starts a campaign whose every lead has finished so it goes active and waits for leads instead of answering 400, return waiting_for_leads from the start endpoint for the dialog's success screen, and document the no_leads and no_remaining_leads codes and the new behaviour in the campaigns, forms, automations and API docs
This commit is contained in:
@@ -2788,7 +2788,7 @@ function NativeActionConfig({
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 leading-relaxed">
|
||||
Enrols the contact this event is about (by <code>contact_id</code> or <code>contact_email</code>). Sending still runs through the campaign's mailboxes, daily caps and spacing.
|
||||
Enrols the contact this event is about (by <code>contact_id</code> or <code>contact_email</code>). Sending still runs through the campaign's mailboxes, daily caps and spacing, and the campaign keeps running for new leads instead of finishing between runs.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -2942,7 +2942,7 @@ function UpsertContactFields({
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 leading-relaxed">
|
||||
A blank value never erases what the contact already has. The written contact becomes this event's contact, so the
|
||||
steps after it (tag, task, deal) act on it.
|
||||
steps after it (tag, task, deal) act on it. A campaign picked here keeps running for new leads instead of finishing between runs.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -23,9 +23,13 @@ import {
|
||||
import type Campaign from "@/lib/api/models/app/campaigns/Campaign";
|
||||
import type { AppError } from "@/lib/api/client/normalizeError";
|
||||
import buildError from "@/lib/helper/buildError";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import useCampaign from "@/lib/api/hooks/app/campaigns/useCampaign";
|
||||
import usePreflight from "@/lib/api/hooks/app/campaigns/usePreflight";
|
||||
import getSequences from "@/lib/api/client/app/campaigns/sequences/getSequences";
|
||||
import { preflightFailures } from "@/lib/api/models/app/campaigns/Preflight";
|
||||
import { isIdleCampaign } from "@/components/app/campaigns/status";
|
||||
import type { StartCampaignResult } from "@/lib/api/client/app/campaigns/startCampaign";
|
||||
|
||||
type Phase = "idle" | "launching" | "done";
|
||||
|
||||
@@ -95,14 +99,24 @@ export default function LaunchCampaignDialog({
|
||||
// The backend refused the launch on projected bounce rate. The member can
|
||||
// take the risk explicitly (a list verified elsewhere), once they read why.
|
||||
const [riskBlocked, setRiskBlocked] = React.useState(false);
|
||||
// The start answered that the campaign is active but waiting for leads.
|
||||
const [waiting, setWaiting] = React.useState(false);
|
||||
const timer = React.useRef<number | null>(null);
|
||||
|
||||
// The list passes a campaign whose `sequences` is null (the list endpoint
|
||||
// omits them), so fetch the full record to show an accurate step count and
|
||||
// The list passes a trimmed campaign, so fetch the full record for accurate
|
||||
// settings. Falls back to the passed campaign until it resolves.
|
||||
const full = useCampaign(campaign?.id ?? "");
|
||||
const c = full.data ?? campaign;
|
||||
|
||||
// Steps live on their own endpoint (the campaign record never carries
|
||||
// them), under the same key the sequence editor uses so the cache is shared.
|
||||
const steps = useQuery({
|
||||
queryKey: ["campaigns", campaign?.id ?? "", "sequences"],
|
||||
queryFn: () => getSequences(campaign?.id ?? ""),
|
||||
enabled: !!campaign,
|
||||
});
|
||||
const stepCount = steps.data?.length;
|
||||
|
||||
// The platform's own pre-send checks, run fresh each time the dialog opens.
|
||||
// Advisory: a failure is shown, never used to disable Launch, because the
|
||||
// backend owns what actually blocks a start.
|
||||
@@ -115,6 +129,7 @@ export default function LaunchCampaignDialog({
|
||||
setPhase("idle");
|
||||
setError(null);
|
||||
setRiskBlocked(false);
|
||||
setWaiting(false);
|
||||
}
|
||||
}, [campaign]);
|
||||
|
||||
@@ -139,7 +154,10 @@ export default function LaunchCampaignDialog({
|
||||
setError(null);
|
||||
setPhase("launching");
|
||||
try {
|
||||
await onConfirm(campaign.id, acknowledge ? { acknowledge_list_risk: true } : undefined);
|
||||
const res = (await onConfirm(campaign.id, acknowledge ? { acknowledge_list_risk: true } : undefined)) as
|
||||
| StartCampaignResult
|
||||
| undefined;
|
||||
setWaiting(!!res?.waiting_for_leads);
|
||||
setPhase("done");
|
||||
timer.current = window.setTimeout(onClose, 1200);
|
||||
} catch (e) {
|
||||
@@ -189,10 +207,12 @@ export default function LaunchCampaignDialog({
|
||||
<CheckIcon className="w-6 h-6" strokeWidth={2.6} />
|
||||
</motion.div>
|
||||
<p className="mt-3 text-[14px] font-semibold text-slate-900">
|
||||
You're live
|
||||
{waiting || isIdleCampaign(c) ? "Running, waiting for leads" : "You're live"}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[12px] text-slate-500 truncate max-w-full">
|
||||
{c.name} is now sending
|
||||
{waiting || isIdleCampaign(c)
|
||||
? `${c.name} sends to leads as they arrive`
|
||||
: `${c.name} is now sending`}
|
||||
</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
@@ -242,11 +262,11 @@ export default function LaunchCampaignDialog({
|
||||
icon={<ListChecksIcon className="w-4 h-4" />}
|
||||
label="Steps"
|
||||
value={
|
||||
full.isLoading && !c.sequences
|
||||
? "…"
|
||||
: c.sequences
|
||||
? `${c.sequences.length} step${c.sequences.length === 1 ? "" : "s"}`
|
||||
: "0 steps"
|
||||
steps.isError
|
||||
? "Unavailable"
|
||||
: stepCount === undefined
|
||||
? "…"
|
||||
: `${stepCount} step${stepCount === 1 ? "" : "s"}`
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -307,6 +327,11 @@ export default function LaunchCampaignDialog({
|
||||
<p className="px-5 pt-3 text-[11.5px] text-slate-500 leading-relaxed">
|
||||
Sending begins immediately, paced to the schedule and your
|
||||
mailbox guardrails. You can pause anytime.
|
||||
{c.continuous
|
||||
? " Out of leads, the campaign stays active and waits for new ones."
|
||||
: c.status === "completed"
|
||||
? " If every lead has already finished, the campaign stays active and waits for new ones."
|
||||
: ""}
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -270,7 +270,7 @@ export function LeadFlowSection({
|
||||
/>
|
||||
<SettingRow
|
||||
title="Keep running for new leads"
|
||||
description="Out of leads, the campaign stays active and waits instead of finishing, so leads from a linked segment, the API or an automation keep getting the sequence. Linking a segment turns this on."
|
||||
description="Out of leads, the campaign stays active and waits instead of finishing, so leads from a linked segment, a form, the API or an automation keep getting the sequence. Linking a segment, a form or an automation to this campaign turns it on, and so does starting the campaign once every lead has finished."
|
||||
control={
|
||||
<Toggle
|
||||
id="campaign-pref-continuous"
|
||||
|
||||
@@ -105,7 +105,7 @@ export default function SettingsPanel({
|
||||
/>
|
||||
<p className="text-[11px] text-slate-500 mt-1">
|
||||
New contacts join this campaign as leads. Sending still follows the campaign's own schedule and
|
||||
limits.
|
||||
limits. The campaign keeps running for new leads instead of finishing between submissions.
|
||||
</p>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
Reference in New Issue
Block a user