mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-03 08:02:04 +00:00
feat: add the Placement tests dashboard with the test list, detail and comparison views, New test dialog, Seed inboxes tab, campaign step Test inbox placement action and Placement monitor card, live updates over PLACEMENT_TEST_UPDATED with a slow poll only while the socket is down, the two placement notification categories, and seed-aware warmup errors on Mailboxes
This commit is contained in:
@@ -55,9 +55,9 @@ A test runs against one panel of seed inboxes:
|
||||
|
||||
| Panel | Whose seeds | Available |
|
||||
|-------|-------------|-----------|
|
||||
| **Instance** | The panel the instance operator runs for every workspace. On the hosted product this is Warmbly's own | Whenever the operator has put seed inboxes on it. On a self-hosted instance, an administrator adds them from the admin panel |
|
||||
| **Your seed inboxes** | Mailboxes of your own workspace that you marked as seeds | Once you mark at least one |
|
||||
| **Warmbly Cloud** | Warmbly Cloud's panel | Only on a self-hosted instance [linked to Warmbly Cloud](/guides/warmbly-cloud/#placement-tests-on-warmbly-clouds-seed-panel) |
|
||||
| **Shared panel** | The panel the instance operator runs for every workspace (`instance` in the API). On the hosted product this is Warmbly's own | Whenever the operator has put seed inboxes on it. On a self-hosted instance, an administrator adds them from the admin panel |
|
||||
| **Your seed inboxes** | Mailboxes of your own workspace that you marked as seeds (`workspace` in the API) | Once you mark at least one |
|
||||
| **Warmbly Cloud panel** | Warmbly Cloud's panel (`cloud` in the API) | Only on a self-hosted instance [linked to Warmbly Cloud](/guides/warmbly-cloud/#placement-tests-on-warmbly-clouds-seed-panel) |
|
||||
|
||||
The page shows each panel's seed count and its mix of providers, and says why a panel is unavailable when it is.
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import CampaignContactOrder from "@/components/app/campaigns/preferences/CampaignContactOrder";
|
||||
import { FirstEmailSection } from "@/components/app/campaigns/preferences/CampaignFirstEmail";
|
||||
import { GuardrailsSection } from "@/components/app/campaigns/preferences/CampaignGuardrails";
|
||||
import { PlacementMonitorSection } from "@/components/app/campaigns/preferences/CampaignPlacementMonitor";
|
||||
import { guardrailValidationError } from "@/lib/helper/guardrail";
|
||||
import CampaignFolderField from "@/components/app/campaigns/CampaignFolderField";
|
||||
import CampaignDangerZone from "@/components/app/campaigns/preferences/CampaignDangerZone";
|
||||
@@ -61,6 +62,11 @@ const SECTIONS = [
|
||||
label: "Auto-pause",
|
||||
description: "Stop this campaign automatically when its bounce, complaint, or reply rate leaves the band you set.",
|
||||
},
|
||||
{
|
||||
id: "placement",
|
||||
label: "Placement monitor",
|
||||
description: "Test where this campaign's first email lands on a schedule, and get told when it slips.",
|
||||
},
|
||||
{
|
||||
id: "first-email",
|
||||
label: "First email",
|
||||
@@ -382,6 +388,8 @@ export default function CampaignPreferences() {
|
||||
explicitAccounts={explicitAccounts}
|
||||
/>
|
||||
);
|
||||
case "placement":
|
||||
return <PlacementMonitorSection campaignId={campaign.id} />;
|
||||
case "first-email":
|
||||
return <FirstEmailSection newCampaign={newData} setNewCampaign={setNewData} />;
|
||||
case "leadflow":
|
||||
|
||||
@@ -298,13 +298,26 @@ export default function DeliverabilityPage() {
|
||||
|
||||
{show("providers") && (
|
||||
<>
|
||||
<SectionBar label="Seed test placement by provider" count={d?.by_provider?.length || undefined} />
|
||||
<SectionBar label="Seed test placement by provider" count={d?.by_provider?.length || undefined}>
|
||||
<Link to="/app/placement" className="inline-flex items-center gap-1 text-[11px] text-slate-500 hover:text-slate-900 transition-colors">
|
||||
Run a placement test
|
||||
<ArrowUpRightIcon className="w-3 h-3" />
|
||||
</Link>
|
||||
</SectionBar>
|
||||
{q.isPending ? (
|
||||
<SkeletonRows />
|
||||
) : (d?.by_provider?.length ?? 0) === 0 ? (
|
||||
<EmptyBlock
|
||||
title="No placement test samples in this window"
|
||||
body="Run a seed placement test to see where your mail lands (inbox, promotions, or spam) at each provider."
|
||||
body="Run a placement test to see where your mail lands (inbox, a Gmail tab, spam, or nowhere) at each provider."
|
||||
cta={
|
||||
<Link
|
||||
to="/app/placement"
|
||||
className="h-7 px-2.5 rounded-md inline-flex items-center gap-1.5 text-[12px] font-medium bg-sky-600 hover:bg-sky-700 text-white transition-colors"
|
||||
>
|
||||
Run a placement test
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-200/60">
|
||||
@@ -418,11 +431,12 @@ function ProviderRow({ p }: { p: ProviderPlacement }) {
|
||||
{ n: p.inbox, tone: "emerald" as DitherTone, label: "Inbox" },
|
||||
{ n: p.promotions, tone: "violet" as DitherTone, label: "Promotions" },
|
||||
{ n: p.spam, tone: "rose" as DitherTone, label: "Spam" },
|
||||
{ n: p.other, tone: "slate" as DitherTone, label: "Other" },
|
||||
{ n: p.other, tone: "sky" as DitherTone, label: "Other tabs" },
|
||||
{ n: p.missing ?? 0, tone: "slate" as DitherTone, label: "Never arrived" },
|
||||
].filter((s) => s.n > 0);
|
||||
return (
|
||||
<div className="h-11 px-5 flex items-center gap-3">
|
||||
<span className="text-[12.5px] font-medium text-slate-900 w-28 shrink-0 truncate">{providerLabel(p.provider)}</span>
|
||||
<span className="text-[12.5px] font-medium text-slate-900 w-28 shrink-0 truncate">{p.label || providerLabel(p.provider)}</span>
|
||||
<div className="flex-1 min-w-16" title={segments.map((s) => `${s.label} ${s.n}`).join(" · ")}>
|
||||
<DitherStack
|
||||
segments={segments.map((s) => ({ frac: s.n / Math.max(1, p.samples), tone: s.tone }))}
|
||||
@@ -432,6 +446,9 @@ function ProviderRow({ p }: { p: ProviderPlacement }) {
|
||||
<span className="flex items-center gap-2 md:gap-4 font-mono text-[11px] tabular-nums shrink-0">
|
||||
<span title="Inbox rate" className="text-emerald-600">{pct(p.inbox_rate)} inbox</span>
|
||||
<span title="Spam rate" className="text-rose-600">{pct(p.spam_rate)} spam</span>
|
||||
{(p.missing ?? 0) > 0 && (
|
||||
<span title="Copies that never arrived" className="hidden sm:inline text-slate-500">{num(p.missing)} missing</span>
|
||||
)}
|
||||
<span title="Samples" className="hidden md:inline text-slate-500">{num(p.samples)} samples</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -224,7 +224,12 @@ export default function AddressesPage() {
|
||||
await queryClient.invalidateQueries({ queryKey: ["analytics", "accounts"] });
|
||||
setSelected([]);
|
||||
const verb = action === "start" ? "started" : "paused";
|
||||
if (failed > 0) toast.error(`${failed} mailbox${failed > 1 ? "es" : ""} couldn't be updated`);
|
||||
const seeds = results.filter(
|
||||
(r) => r.status === "rejected" && (r.reason as AppError | null)?.code === "mailbox_is_seed",
|
||||
).length;
|
||||
if (failed > 0 && seeds === failed) {
|
||||
toast.error(`${seeds} mailbox${seeds > 1 ? "es are placement seed inboxes" : " is a placement seed inbox"}, and seeds never warm up.`);
|
||||
} else if (failed > 0) toast.error(`${failed} mailbox${failed > 1 ? "es" : ""} couldn't be updated`);
|
||||
else toast.success(`Warmup ${verb} for ${n} mailbox${n > 1 ? "es" : ""}`);
|
||||
};
|
||||
|
||||
@@ -690,7 +695,7 @@ function MailboxRow({
|
||||
const run = (action: "start" | "pause" | "resume", verb: string) => {
|
||||
life.mutate(action, {
|
||||
onSuccess: () => toast.success(`Warmup ${verb} for ${box.email}`),
|
||||
onError: () => toast.error("Couldn't update warmup"),
|
||||
onError: (e) => toast.error(warmupErrorMessage(e as unknown as AppError)),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -701,8 +706,8 @@ function MailboxRow({
|
||||
try {
|
||||
await life.mutateAsync("stop");
|
||||
toast.success(`Warmup stopped for ${box.email}`);
|
||||
} catch {
|
||||
toast.error("Couldn't update warmup");
|
||||
} catch (e) {
|
||||
toast.error(warmupErrorMessage(e as AppError));
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -923,3 +928,13 @@ function MailboxRow({
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// A refusal the server explains with a code (a seed inbox, a blocked pool)
|
||||
// reads better as its own sentence than as a generic failure.
|
||||
function warmupErrorMessage(e: AppError | null | undefined): string {
|
||||
if (e?.code === "mailbox_is_seed") {
|
||||
return e.message || "This mailbox is a placement seed inbox, and seeds never warm up. Remove it from your seed inboxes first.";
|
||||
}
|
||||
if (e?.code && e.message) return e.message;
|
||||
return "Couldn't update warmup";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
// One placement test: where every copy landed, by folder, provider and seed,
|
||||
// the content check of the copy that was sent, and for a tracking comparison
|
||||
// the two halves side by side. Live through PLACEMENT_TEST_UPDATED.
|
||||
|
||||
import React from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { ArrowLeftIcon, ArrowUpRightIcon, Loader2Icon, SquareIcon } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
import { EmptyBlock, SectionBar } from "@/components/layout/Page";
|
||||
import PermissionButton from "@/components/ui/PermissionButton";
|
||||
import EmailBody from "@/components/app/unibox/EmailBody";
|
||||
import { IssueRow } from "@/components/app/campaigns/ContentScore";
|
||||
import { useConfirm } from "@/hooks/context/confirm";
|
||||
import useCampaign from "@/lib/api/hooks/app/campaigns/useCampaign";
|
||||
import { useCancelPlacementTest, usePlacementTest } from "@/lib/api/hooks/app/placement/usePlacement";
|
||||
import {
|
||||
PANEL_LABEL,
|
||||
type PlacementCounts,
|
||||
type PlacementResult,
|
||||
type PlacementTest,
|
||||
type PlacementTestDetail,
|
||||
} from "@/lib/api/models/app/placement/Placement";
|
||||
import type { AppError } from "@/lib/api/client/normalizeError";
|
||||
import buildError from "@/lib/helper/buildError";
|
||||
import {
|
||||
FolderChip,
|
||||
PlacementBar,
|
||||
PlacementCaveat,
|
||||
PlacementLegend,
|
||||
StatusChip,
|
||||
TrackingBadge,
|
||||
} from "@/components/app/placement/tests/PlacementParts";
|
||||
import {
|
||||
FOLDER,
|
||||
ORIGIN_LABEL,
|
||||
fmtDate,
|
||||
fmtRate,
|
||||
isTracked,
|
||||
rateTone,
|
||||
resolvedCount,
|
||||
} from "@/components/app/placement/tests/placementTests";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function PlacementTestPage() {
|
||||
const { id = "" } = useParams();
|
||||
const q = usePlacementTest(id);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-full bg-white">
|
||||
<div className="px-3 sm:px-5 pt-3 sm:pt-4">
|
||||
<Link
|
||||
to="/app/placement"
|
||||
className="inline-flex items-center gap-1 h-6 -ml-1.5 px-1.5 mb-1 rounded-md text-[11.5px] text-slate-500 hover:text-slate-900 hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
<ArrowLeftIcon className="w-3 h-3" />
|
||||
Placement tests
|
||||
</Link>
|
||||
</div>
|
||||
{q.isLoading ? (
|
||||
<div className="px-5 py-16 flex justify-center">
|
||||
<Loader2Icon className="w-5 h-5 animate-spin text-slate-300" />
|
||||
</div>
|
||||
) : q.isError || !q.data ? (
|
||||
<EmptyBlock
|
||||
title={(q.error as unknown as AppError)?.status === 404 ? "This test does not exist" : "This test could not be loaded"}
|
||||
body={(q.error as unknown as AppError)?.status === 404 ? "It may belong to another workspace." : q.error ? buildError(q.error as unknown as AppError) : undefined}
|
||||
/>
|
||||
) : (
|
||||
<Detail test={q.data} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Detail({ test }: { test: PlacementTestDetail }) {
|
||||
const confirm = useConfirm();
|
||||
const cancel = useCancelPlacementTest();
|
||||
const campaign = useCampaign(test.campaign_id ?? "");
|
||||
const running = test.status === "running";
|
||||
const s = test.summary;
|
||||
|
||||
const onCancel = () =>
|
||||
confirm.show(
|
||||
"Stop this test? Copies not sent yet are cancelled. The ones already sent keep being classified.",
|
||||
async () => {
|
||||
try {
|
||||
await cancel.mutateAsync(test.id);
|
||||
toast.success("Test stopped.");
|
||||
} catch (e) {
|
||||
const err = e as AppError;
|
||||
toast.error(err?.code === "placement_not_running" ? "This test has already finished." : buildError(err));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="px-3 sm:px-5 pb-4 flex flex-wrap items-start gap-3 border-b border-slate-200">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<h1 className="min-w-0 max-w-full text-[18px] font-semibold text-slate-900 truncate">{test.subject || "(no subject)"}</h1>
|
||||
<StatusChip status={test.status} counts={s} />
|
||||
<TrackingBadge test={test} />
|
||||
</div>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11.5px] text-slate-500">
|
||||
<span>
|
||||
From <span className="text-slate-800">{test.sender_email || "a deleted mailbox"}</span>
|
||||
</span>
|
||||
<span>{PANEL_LABEL[test.panel] ?? test.panel}</span>
|
||||
<span>Started {fmtDate(test.created_at)}</span>
|
||||
{test.finished_at && <span>Finished {fmtDate(test.finished_at)}</span>}
|
||||
{test.origin !== "manual" && <span>{ORIGIN_LABEL[test.origin] ?? test.origin}</span>}
|
||||
{test.campaign_id && (
|
||||
<Link to={`/app/campaigns/${test.campaign_id}/steps`} className="inline-flex items-center gap-0.5 text-sky-700 hover:text-sky-800">
|
||||
{campaign.data?.name ?? "Campaign"}
|
||||
<ArrowUpRightIcon className="w-3 h-3" />
|
||||
</Link>
|
||||
)}
|
||||
{test.compare && (
|
||||
<Link to={`/app/placement/${test.compare.id}`} className="inline-flex items-center gap-0.5 text-sky-700 hover:text-sky-800">
|
||||
{isTracked(test.compare) ? "Tracked half" : "Untracked half"}
|
||||
<ArrowUpRightIcon className="w-3 h-3" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{test.error && <p className="mt-1.5 text-[11.5px] text-rose-600">{test.error}</p>}
|
||||
</div>
|
||||
{running && (
|
||||
<PermissionButton
|
||||
permission="SEND_CAMPAIGNS"
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={cancel.isPending}
|
||||
className="shrink-0 h-7 px-2.5 rounded-md border border-slate-200 hover:border-slate-300 bg-white text-[12px] font-medium text-slate-700 hover:text-slate-900 inline-flex items-center gap-1.5 transition-colors disabled:opacity-60"
|
||||
>
|
||||
{cancel.isPending ? <Loader2Icon className="w-3.5 h-3.5 animate-spin" /> : <SquareIcon className="w-3 h-3" />}
|
||||
Stop test
|
||||
</PermissionButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Folder breakdown */}
|
||||
<Breakdown counts={s} running={running} />
|
||||
|
||||
{test.compare && <Comparison test={test} other={test.compare} />}
|
||||
|
||||
{/* By provider */}
|
||||
<SectionBar label="By provider" count={test.families?.length || undefined}>
|
||||
<PlacementLegend className="hidden md:flex" />
|
||||
</SectionBar>
|
||||
{(test.families ?? []).length === 0 ? (
|
||||
<p className="px-5 py-4 text-[12px] text-slate-400">No copies have a verdict yet.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
<tr className="h-8 border-b border-slate-200/60 text-[10px] uppercase tracking-[0.14em] text-slate-400">
|
||||
<th className="px-5 font-medium">Provider</th>
|
||||
<th className="px-3 font-medium w-[30%] hidden sm:table-cell" />
|
||||
<th className="px-3 font-medium text-right">Inbox</th>
|
||||
<th className="px-3 font-medium text-right">Tabs</th>
|
||||
<th className="px-3 font-medium text-right">Spam</th>
|
||||
<th className="px-3 font-medium text-right">Missing</th>
|
||||
<th className="px-5 font-medium text-right hidden md:table-cell">Copies</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200/60">
|
||||
{(test.families ?? []).map((f) => (
|
||||
<tr key={f.family} className="h-10">
|
||||
<td className="px-5 text-[12.5px] font-medium text-slate-900 whitespace-nowrap">{f.label || f.family}</td>
|
||||
<td className="px-3 hidden sm:table-cell">
|
||||
<PlacementBar counts={f.counts} />
|
||||
</td>
|
||||
<td className={cn("px-3 text-right font-mono text-[11.5px] tabular-nums", rateTone(f.counts.inbox_rate))}>
|
||||
{fmtRate(f.counts.inbox_rate)}
|
||||
</td>
|
||||
<td className="px-3 text-right font-mono text-[11.5px] tabular-nums text-violet-600">{fmtRate(f.counts.tabs_rate)}</td>
|
||||
<td className="px-3 text-right font-mono text-[11.5px] tabular-nums text-rose-600">{fmtRate(f.counts.spam_rate)}</td>
|
||||
<td className="px-3 text-right font-mono text-[11.5px] tabular-nums text-slate-500">{fmtRate(f.counts.missing_rate)}</td>
|
||||
<td className="px-5 text-right font-mono text-[11px] tabular-nums text-slate-400 hidden md:table-cell">
|
||||
{resolvedCount(f.counts)}/{f.counts.total}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] border-t border-slate-200">
|
||||
{/* Per seed */}
|
||||
<section className="min-w-0 lg:border-r lg:border-slate-200">
|
||||
<SectionBar label="Seed inboxes" count={test.results?.length || undefined} />
|
||||
<SeedResults results={test.results ?? []} masked={test.panel !== "workspace"} />
|
||||
</section>
|
||||
|
||||
{/* Content check + the copy */}
|
||||
<section className="min-w-0">
|
||||
<SectionBar label="Content check" />
|
||||
<ContentCheck test={test} />
|
||||
<SectionBar label="Copy that was tested" />
|
||||
<div className="px-5 py-3">
|
||||
<div className="rounded-md border border-slate-200 bg-white">
|
||||
<div className="border-b border-slate-200/70 px-3 py-2 text-[12.5px]">
|
||||
<span className="text-slate-400">Subject: </span>
|
||||
<span className="text-slate-800">{test.subject || "(no subject)"}</span>
|
||||
</div>
|
||||
<div className="min-h-[160px] px-3 py-2.5">
|
||||
{test.body_html || test.body_plain ? (
|
||||
<EmailBody html={test.body_html || null} plain={test.body_plain || null} />
|
||||
) : (
|
||||
<p className="text-[12px] text-slate-400">The body is not stored for this test.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[11px] text-slate-400 leading-relaxed">
|
||||
The template as written. Each seed got it rendered for the chosen contact, with the signature,
|
||||
opt-out footer and unsubscribe header the real send adds.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<PlacementCaveat className="mx-5 my-5" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Hairlines for a 2x2 grid on phones and one row of four from md up.
|
||||
const CELL_BORDER = ["border-r max-md:border-b", "md:border-r max-md:border-b", "border-r", ""];
|
||||
|
||||
function Breakdown({ counts, running }: { counts: PlacementCounts; running: boolean }) {
|
||||
const tabs = counts.promotions + counts.other;
|
||||
const cells: { label: string; n: number; rate: number | null; tone: string; dot: string; sub?: string }[] = [
|
||||
{ label: "Inbox", n: counts.inbox, rate: counts.inbox_rate, tone: FOLDER.inbox.text, dot: FOLDER.inbox.dot },
|
||||
{
|
||||
label: "Gmail tabs",
|
||||
n: tabs,
|
||||
rate: counts.tabs_rate,
|
||||
tone: FOLDER.promotions.text,
|
||||
dot: FOLDER.promotions.dot,
|
||||
sub: `${counts.promotions} Promotions, ${counts.other} other tabs`,
|
||||
},
|
||||
{ label: "Spam", n: counts.spam, rate: counts.spam_rate, tone: FOLDER.spam.text, dot: FOLDER.spam.dot },
|
||||
{
|
||||
label: "Never arrived",
|
||||
n: counts.missing,
|
||||
rate: counts.missing_rate,
|
||||
tone: FOLDER.missing.text,
|
||||
dot: FOLDER.missing.dot,
|
||||
sub: "Not seen within 2 hours",
|
||||
},
|
||||
];
|
||||
const notSent = counts.failed + counts.cancelled;
|
||||
return (
|
||||
<section className="border-b border-slate-200">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4">
|
||||
{cells.map((c, i) => (
|
||||
<div
|
||||
key={c.label}
|
||||
className={cn("px-5 py-4 border-slate-200", CELL_BORDER[i])}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("size-1.5 rounded-full", c.dot)} />
|
||||
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">{c.label}</span>
|
||||
</div>
|
||||
<div className={cn("mt-2 text-[26px] font-light leading-none tabular-nums", c.rate == null ? "text-slate-300" : c.tone)}>
|
||||
{fmtRate(c.rate)}
|
||||
</div>
|
||||
<div className="mt-1.5 text-[10.5px] text-slate-400 font-mono truncate">
|
||||
{c.n} cop{c.n === 1 ? "y" : "ies"}
|
||||
{c.sub ? `, ${c.sub}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="px-5 pb-4 pt-1">
|
||||
<PlacementBar counts={counts} height={8} />
|
||||
<p className="mt-2 text-[11px] text-slate-500">
|
||||
{running
|
||||
? `${resolvedCount(counts)} of ${counts.total} copies have a verdict. This page updates as they arrive.`
|
||||
: `${counts.delivered} of ${counts.total} copies were sent and classified.`}
|
||||
{notSent > 0 && ` ${notSent} not sent (${counts.failed} failed, ${counts.cancelled} cancelled).`}
|
||||
{" "}Rates are shares of the copies that were sent.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// Two tests to the same seeds, one untracked and one tracked, side by side.
|
||||
function Comparison({ test, other }: { test: PlacementTest; other: PlacementTest }) {
|
||||
const [without, withT] = isTracked(test) ? [other, test] : [test, other];
|
||||
const rows: { label: string; key: keyof PlacementCounts }[] = [
|
||||
{ label: "Inbox", key: "inbox_rate" },
|
||||
{ label: "Gmail tabs", key: "tabs_rate" },
|
||||
{ label: "Spam", key: "spam_rate" },
|
||||
{ label: "Never arrived", key: "missing_rate" },
|
||||
];
|
||||
const diff = (a: number | null, b: number | null) => (a == null || b == null ? null : Math.round((b - a) * 100));
|
||||
const inboxDelta = diff(without.summary.inbox_rate, withT.summary.inbox_rate);
|
||||
return (
|
||||
<section className="border-b border-slate-200">
|
||||
<SectionBar label="Tracking comparison" />
|
||||
<div className="grid sm:grid-cols-2">
|
||||
{[
|
||||
{ title: "Without tracking", t: without },
|
||||
{ title: "With tracking", t: withT },
|
||||
].map(({ title, t }, i) => (
|
||||
<div key={t.id} className={cn("px-5 py-4 min-w-0", i === 0 && "sm:border-r border-slate-200 max-sm:border-b")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[12.5px] font-medium text-slate-900">{title}</span>
|
||||
<StatusChip status={t.status} counts={t.summary} />
|
||||
{t.id !== test.id && (
|
||||
<Link to={`/app/placement/${t.id}`} className="ml-auto text-[11px] text-sky-700 hover:text-sky-800 inline-flex items-center gap-0.5">
|
||||
Open
|
||||
<ArrowUpRightIcon className="w-3 h-3" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<PlacementBar counts={t.summary} className="mt-3" />
|
||||
<dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-1.5">
|
||||
{rows.map((r) => (
|
||||
<div key={r.key} className="flex items-center justify-between gap-2">
|
||||
<dt className="text-[11.5px] text-slate-500">{r.label}</dt>
|
||||
<dd className="font-mono text-[12px] tabular-nums text-slate-800">{fmtRate(t.summary[r.key] as number | null)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="px-5 pb-4 text-[11.5px] text-slate-500 leading-relaxed">
|
||||
{inboxDelta == null
|
||||
? "The difference shows once both halves have verdicts."
|
||||
: inboxDelta === 0
|
||||
? "Tracking made no difference to how much reached the inbox in this test."
|
||||
: inboxDelta < 0
|
||||
? `The tracked copies reached the inbox ${Math.abs(inboxDelta)} points less often. Run it again before turning tracking off: one test is noise.`
|
||||
: `The tracked copies reached the inbox ${inboxDelta} points more often. With this few seeds that is within the noise.`}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SeedResults({ results, masked }: { results: PlacementResult[]; masked: boolean }) {
|
||||
if (results.length === 0) return <p className="px-5 py-4 text-[12px] text-slate-400">No seeds were picked for this test.</p>;
|
||||
return (
|
||||
<>
|
||||
{masked && (
|
||||
<p className="px-5 pt-3 text-[11px] text-slate-400">Addresses on a shared panel are partly hidden.</p>
|
||||
)}
|
||||
<ul className="divide-y divide-slate-200/60">
|
||||
{results.map((r, i) => (
|
||||
<li key={`${r.seed}-${i}`} className="px-5 py-2 flex items-center gap-3 min-h-11">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[12.5px] text-slate-800 font-mono truncate">{r.seed}</div>
|
||||
<div className="text-[11px] text-slate-400 truncate">
|
||||
{r.family_label || r.family}
|
||||
{r.detected_at
|
||||
? `, seen ${fmtDate(r.detected_at)}`
|
||||
: r.sent_at
|
||||
? `, sent ${fmtDate(r.sent_at)}`
|
||||
: r.scheduled_at && r.folder === "pending"
|
||||
? `, sends ${fmtDate(r.scheduled_at)}`
|
||||
: ""}
|
||||
</div>
|
||||
{r.error && <div className="text-[11px] text-amber-600 truncate" title={r.error}>{r.error}</div>}
|
||||
</div>
|
||||
<FolderChip folder={r.folder} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ContentCheck({ test }: { test: PlacementTestDetail }) {
|
||||
const { score } = test.content;
|
||||
const issues = test.content.issues ?? [];
|
||||
const tone = score >= 80 ? "text-emerald-600" : score >= 50 ? "text-amber-600" : "text-rose-600";
|
||||
const label = score >= 80 ? "Looks good" : score >= 50 ? "Could improve" : "Needs work";
|
||||
return (
|
||||
<div className="px-5 py-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className={cn("text-[22px] font-light tabular-nums", tone)}>{score}</span>
|
||||
<span className="text-[11px] text-slate-400">out of 100</span>
|
||||
<span className={cn("text-[11.5px] font-medium", tone)}>{label}</span>
|
||||
</div>
|
||||
{issues.length === 0 ? (
|
||||
<p className="mt-1 text-[11.5px] text-slate-500">Nothing in the copy stands out to a spam filter.</p>
|
||||
) : (
|
||||
<ul className="mt-1 divide-y divide-slate-100">
|
||||
{issues.map((issue, i) => (
|
||||
<IssueRow key={`${issue.code}-${i}`} issue={issue} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="mt-2 text-[11px] text-slate-400 leading-relaxed">
|
||||
The same rules the step editor checks, run on the copy that was tested. Placement also depends on the
|
||||
sender's reputation, which no content check sees.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
// Inbox placement tests: the workspace's tests, the seed panels it can test
|
||||
// against, and its own seed inboxes. Live through PLACEMENT_TEST_UPDATED and
|
||||
// the audit spine; nothing here polls.
|
||||
|
||||
import React from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { motion } from "framer-motion";
|
||||
import { InboxIcon, Loader2Icon, ListIcon, PlusIcon, XIcon } from "lucide-react";
|
||||
import { EmptyBlock, Page, PageTopbar, SectionBar, TopbarAction } from "@/components/layout/Page";
|
||||
import ScrollStrip from "@/components/ui/scroll-strip";
|
||||
import { usePermission, showPermissionDenied } from "@/hooks/usePermission";
|
||||
import useCampaign from "@/lib/api/hooks/app/campaigns/useCampaign";
|
||||
import { usePlacementOverview, usePlacementTests } from "@/lib/api/hooks/app/placement/usePlacement";
|
||||
import { PANEL_LABEL, type PlacementTest } from "@/lib/api/models/app/placement/Placement";
|
||||
import type { AppError } from "@/lib/api/client/normalizeError";
|
||||
import buildError from "@/lib/helper/buildError";
|
||||
import NewPlacementTestDialog from "@/components/app/placement/tests/NewPlacementTestDialog";
|
||||
import SeedInboxes from "@/components/app/placement/tests/SeedInboxes";
|
||||
import {
|
||||
PanelStrip,
|
||||
PlacementBar,
|
||||
PlacementCaveat,
|
||||
PlacementLegend,
|
||||
StatusChip,
|
||||
TrackingBadge,
|
||||
} from "@/components/app/placement/tests/PlacementParts";
|
||||
import { ORIGIN_LABEL, fmtDate, fmtRate, rateTone, usageLabel } from "@/components/app/placement/tests/placementTests";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Tab = "tests" | "seeds";
|
||||
|
||||
const TABS: { key: Tab; label: string; icon: typeof ListIcon }[] = [
|
||||
{ key: "tests", label: "Tests", icon: ListIcon },
|
||||
{ key: "seeds", label: "Seed inboxes", icon: InboxIcon },
|
||||
];
|
||||
|
||||
export default function PlacementPage() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const tab: Tab = params.get("tab") === "seeds" ? "seeds" : "tests";
|
||||
const campaignId = params.get("campaign_id");
|
||||
const canStart = usePermission("SEND_CAMPAIGNS");
|
||||
const overview = usePlacementOverview();
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||
|
||||
const setTab = (t: Tab) => {
|
||||
const next = new URLSearchParams(params);
|
||||
if (t === "tests") next.delete("tab");
|
||||
else next.set("tab", t);
|
||||
setParams(next, { replace: true });
|
||||
};
|
||||
|
||||
const openNew = () => {
|
||||
if (!canStart) {
|
||||
showPermissionDenied("SEND_CAMPAIGNS");
|
||||
return;
|
||||
}
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const usage = overview.data?.usage;
|
||||
const exhausted = usage?.limit != null && usage.used >= usage.limit;
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageTopbar
|
||||
eyebrow="Placement tests"
|
||||
subtitle={overview.data ? usageLabel(overview.data) : undefined}
|
||||
>
|
||||
{exhausted && (
|
||||
<span className="hidden md:inline text-[11px] text-slate-400">Your own seed inboxes are never counted.</span>
|
||||
)}
|
||||
<TopbarAction icon={<PlusIcon className="w-3.5 h-3.5" />} onClick={openNew}>
|
||||
New test
|
||||
</TopbarAction>
|
||||
</PageTopbar>
|
||||
|
||||
<ScrollStrip activeKey={tab} className="shrink-0 border-b border-slate-200" innerClassName="px-3 gap-1">
|
||||
{TABS.map((t) => {
|
||||
const active = tab === t.key;
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
data-active={active}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={cn(
|
||||
"relative h-10 px-2.5 inline-flex shrink-0 items-center gap-1.5 text-[12.5px] transition-colors",
|
||||
active ? "text-slate-900 font-medium" : "text-slate-500 hover:text-slate-800",
|
||||
)}
|
||||
>
|
||||
<t.icon className="w-3.5 h-3.5" />
|
||||
{t.label}
|
||||
{t.key === "seeds" && overview.data && (
|
||||
<span className="font-mono text-[10.5px] text-slate-400 tabular-nums">{overview.data.workspace_seeds}</span>
|
||||
)}
|
||||
{active && (
|
||||
<motion.span
|
||||
layoutId="placement-tab-underline"
|
||||
className="absolute left-1.5 right-1.5 bottom-0 h-0.5 rounded-full bg-sky-600"
|
||||
transition={{ type: "spring", duration: 0.3, bounce: 0.15 }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</ScrollStrip>
|
||||
|
||||
{tab === "seeds" ? (
|
||||
<SeedInboxes />
|
||||
) : (
|
||||
<>
|
||||
{overview.data && <PanelStrip overview={overview.data} />}
|
||||
<TestsTable
|
||||
campaignId={campaignId}
|
||||
onClearCampaign={() => {
|
||||
const next = new URLSearchParams(params);
|
||||
next.delete("campaign_id");
|
||||
setParams(next, { replace: true });
|
||||
}}
|
||||
onNew={openNew}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<NewPlacementTestDialog
|
||||
open={dialogOpen}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
prefill={campaignId ? { campaignId } : undefined}
|
||||
/>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
function TestsTable({
|
||||
campaignId,
|
||||
onClearCampaign,
|
||||
onNew,
|
||||
}: {
|
||||
campaignId: string | null;
|
||||
onClearCampaign: () => void;
|
||||
onNew: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const list = usePlacementTests(campaignId);
|
||||
const campaign = useCampaign(campaignId ?? "");
|
||||
|
||||
return (
|
||||
<section className="flex-1 min-h-0 flex flex-col">
|
||||
<SectionBar label="Tests" count={list.total ?? undefined}>
|
||||
{campaignId && (
|
||||
<span className="inline-flex items-center gap-1 h-6 pl-2 pr-1 rounded-md bg-sky-50 text-sky-700 text-[11.5px] max-w-[260px]">
|
||||
<span className="truncate">Campaign: {campaign.data?.name ?? "…"}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearCampaign}
|
||||
aria-label="Show every test"
|
||||
className="size-4 rounded inline-flex items-center justify-center hover:bg-sky-100"
|
||||
>
|
||||
<XIcon className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
<PlacementLegend className="hidden lg:flex" />
|
||||
</SectionBar>
|
||||
|
||||
{list.isLoading ? (
|
||||
<div className="divide-y divide-slate-200/60">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="h-12 px-5 flex items-center gap-4">
|
||||
<div className="h-3 w-40 rounded bg-slate-100 animate-pulse" />
|
||||
<div className="h-3 flex-1 rounded bg-slate-50 animate-pulse" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : list.isError ? (
|
||||
<EmptyBlock title="Tests could not be loaded" body={buildError(list.error as unknown as AppError)} />
|
||||
) : list.tests.length === 0 ? (
|
||||
<EmptyBlock
|
||||
title={campaignId ? "No tests for this campaign yet" : "No placement tests yet"}
|
||||
body="Send a campaign step or your own copy to a panel of seed inboxes and see whether it reaches the inbox, a Gmail tab or spam."
|
||||
cta={
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNew}
|
||||
className="h-7 px-2.5 rounded-md inline-flex items-center gap-1.5 text-[12px] font-medium bg-sky-600 hover:bg-sky-700 text-white transition-colors"
|
||||
>
|
||||
<PlusIcon className="w-3.5 h-3.5" />
|
||||
New test
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
<tr className="h-8 border-b border-slate-200/60 text-[10px] uppercase tracking-[0.14em] text-slate-400">
|
||||
<th className="px-5 font-medium">Sender and subject</th>
|
||||
<th className="px-3 font-medium hidden lg:table-cell">Panel</th>
|
||||
<th className="px-3 font-medium hidden md:table-cell">Tracking</th>
|
||||
<th className="px-3 font-medium">Status</th>
|
||||
<th className="px-3 font-medium hidden sm:table-cell w-[18%]">Where it landed</th>
|
||||
<th className="px-3 font-medium text-right">Inbox</th>
|
||||
<th className="px-5 font-medium text-right hidden md:table-cell">Started</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200/60">
|
||||
{list.tests.map((t) => (
|
||||
<TestRow key={t.id} test={t} onOpen={() => navigate(`/app/placement/${t.id}`)} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{list.hasNextPage && (
|
||||
<div className="px-5 py-3 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => list.fetchNextPage()}
|
||||
disabled={list.isFetchingNextPage}
|
||||
className="h-7 px-3 rounded-md border border-slate-200 hover:border-slate-300 text-[12px] font-medium text-slate-700 inline-flex items-center gap-1.5 disabled:opacity-60"
|
||||
>
|
||||
{list.isFetchingNextPage && <Loader2Icon className="w-3.5 h-3.5 animate-spin" />}
|
||||
Load more
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{list.tests.length > 0 && <PlacementCaveat className="mx-5 my-4" />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TestRow({ test, onOpen }: { test: PlacementTest; onOpen: () => void }) {
|
||||
const s = test.summary;
|
||||
return (
|
||||
<tr
|
||||
onClick={onOpen}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onOpen();
|
||||
}}
|
||||
tabIndex={0}
|
||||
className="h-12 cursor-pointer hover:bg-slate-50/80 transition-colors outline-none focus-visible:bg-slate-50"
|
||||
>
|
||||
<td className="px-5 py-2 max-w-0 w-[34%]">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="text-[12.5px] font-medium text-slate-900 truncate">{test.sender_email || "Deleted mailbox"}</span>
|
||||
{test.origin !== "manual" && (
|
||||
<span className="shrink-0 h-4 px-1.5 rounded bg-slate-100 text-[10px] text-slate-500 inline-flex items-center">
|
||||
{ORIGIN_LABEL[test.origin] ?? test.origin}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11.5px] text-slate-500 truncate">{test.subject || "(no subject)"}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 hidden lg:table-cell text-[11.5px] text-slate-600 whitespace-nowrap">{PANEL_LABEL[test.panel] ?? test.panel}</td>
|
||||
<td className="px-3 py-2 hidden md:table-cell">
|
||||
<TrackingBadge test={test} />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<StatusChip status={test.status} counts={s} />
|
||||
</td>
|
||||
<td className="px-3 py-2 hidden sm:table-cell">
|
||||
<PlacementBar counts={s} />
|
||||
</td>
|
||||
<td className={cn("px-3 py-2 text-right font-mono text-[12px] tabular-nums", rateTone(s.inbox_rate))}>
|
||||
{fmtRate(s.inbox_rate)}
|
||||
</td>
|
||||
<td className="px-5 py-2 text-right hidden md:table-cell text-[11px] text-slate-400 whitespace-nowrap">
|
||||
<Link to={`/app/placement/${test.id}`} onClick={(e) => e.stopPropagation()} className="hover:text-slate-700">
|
||||
{fmtDate(test.created_at)}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,8 @@ const HEALTH: { key: NotificationCategoryKey; label: string; hint: string }[] =
|
||||
{ key: "health_worker_downtime", label: "Worker downtime", hint: "A sender worker stops responding." },
|
||||
{ key: "health_domain_auth", label: "Domain authentication failing", hint: "A sending domain lost its SPF or DMARC record. Cold sending and warmup stop from it if it is not fixed." },
|
||||
{ key: "campaign_paused", label: "Campaign auto-paused", hint: "A guardrail stopped a campaign because its bounce, complaint, or reply rate left the band." },
|
||||
{ key: "placement_alert", label: "Placement monitor alert", hint: "A campaign's scheduled placement test found less of its mail in the inbox than its alert threshold." },
|
||||
{ key: "placement_finished", label: "Placement test finished", hint: "A placement test you started has a verdict for every copy." },
|
||||
];
|
||||
|
||||
const SECURITY: { key: NotificationCategoryKey; label: string; hint: string }[] = [
|
||||
@@ -111,6 +113,8 @@ export default function NotificationsSettingsPage() {
|
||||
"health_worker_downtime",
|
||||
"health_domain_auth",
|
||||
"campaign_paused",
|
||||
"placement_alert",
|
||||
"placement_finished",
|
||||
"security_new_signin",
|
||||
"billing_alert",
|
||||
"team_activity",
|
||||
|
||||
@@ -105,7 +105,8 @@ function SpanList({ spans }: { spans: TemplateScoreSpan[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function IssueRow({ issue }: { issue: TemplateScoreIssue }) {
|
||||
// Exported for the placement test detail, which shows the same rules pass.
|
||||
export function IssueRow({ issue }: { issue: TemplateScoreIssue }) {
|
||||
const high = issue.severity === "high";
|
||||
const Icon = high ? AlertCircleIcon : AlertTriangleIcon;
|
||||
const spans = issue.spans ?? [];
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
// A campaign's scheduled placement test: every few days its first email step
|
||||
// is sent to a seed panel from one of its mailboxes, and a low inbox rate
|
||||
// alerts the team (and can pause the campaign). Saves as it changes, through
|
||||
// its own endpoint, apart from the campaign's save bar.
|
||||
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { AlertTriangleIcon, ArrowUpRightIcon, Loader2Icon } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
import { Label, NumberInput } from "@/components/ui/field";
|
||||
import { SelectMenu } from "@/components/ui/select-menu";
|
||||
import { useConfirm } from "@/hooks/context/confirm";
|
||||
import { usePermission } from "@/hooks/usePermission";
|
||||
import {
|
||||
useDeletePlacementMonitor,
|
||||
usePlacementMonitor,
|
||||
usePlacementOverview,
|
||||
usePutPlacementMonitor,
|
||||
} from "@/lib/api/hooks/app/placement/usePlacement";
|
||||
import {
|
||||
PANEL_LABEL,
|
||||
PLACEMENT_MONITOR_INTERVAL_MAX,
|
||||
PLACEMENT_MONITOR_INTERVAL_MIN,
|
||||
type PlacementMonitorInput,
|
||||
type PlacementPanel,
|
||||
} from "@/lib/api/models/app/placement/Placement";
|
||||
import type { AppError } from "@/lib/api/client/normalizeError";
|
||||
import buildError from "@/lib/helper/buildError";
|
||||
import { fmtDate } from "@/components/app/placement/tests/placementTests";
|
||||
import { SettingRow, Toggle } from "./components/CampaignPreferenceBoolBox";
|
||||
|
||||
// Defaults a new monitor starts from, matching the backend's.
|
||||
const DEFAULT_INTERVAL = 7;
|
||||
const DEFAULT_ALERT_BELOW = 70;
|
||||
|
||||
export function PlacementMonitorSection({ campaignId }: { campaignId: string }) {
|
||||
const monitor = usePlacementMonitor(campaignId);
|
||||
const overview = usePlacementOverview();
|
||||
const put = usePutPlacementMonitor(campaignId);
|
||||
const remove = useDeletePlacementMonitor(campaignId);
|
||||
const confirm = useConfirm();
|
||||
const canEdit = usePermission("SEND_CAMPAIGNS");
|
||||
|
||||
const m = monitor.data ?? null;
|
||||
const [intervalDays, setIntervalDays] = React.useState(m?.interval_days ?? DEFAULT_INTERVAL);
|
||||
const [alertBelow, setAlertBelow] = React.useState(m?.alert_below ?? DEFAULT_ALERT_BELOW);
|
||||
const [panel, setPanel] = React.useState<PlacementPanel>(m?.panel ?? "instance");
|
||||
const [pauseOnAlert, setPauseOnAlert] = React.useState(m?.pause_on_alert ?? false);
|
||||
|
||||
// A teammate's edit lands live, so the controls follow the stored monitor.
|
||||
React.useEffect(() => {
|
||||
if (!m) return;
|
||||
setIntervalDays(m.interval_days);
|
||||
setAlertBelow(m.alert_below);
|
||||
setPanel(m.panel);
|
||||
setPauseOnAlert(m.pause_on_alert);
|
||||
}, [m]);
|
||||
|
||||
const save = async (input: PlacementMonitorInput) => {
|
||||
try {
|
||||
await put.mutateAsync(input);
|
||||
} catch (e) {
|
||||
toast.error(buildError(e as AppError));
|
||||
}
|
||||
};
|
||||
|
||||
// Only an existing monitor saves field by field; a new one is created with
|
||||
// every value at once when it is switched on.
|
||||
const change = (input: PlacementMonitorInput) => {
|
||||
if (m) void save(input);
|
||||
};
|
||||
|
||||
const enabled = !!m?.enabled;
|
||||
const toggle = (on: boolean) => {
|
||||
if (!m) {
|
||||
if (on) void save({ enabled: true, interval_days: intervalDays, alert_below: alertBelow, panel, pause_on_alert: pauseOnAlert });
|
||||
return;
|
||||
}
|
||||
void save({ enabled: on });
|
||||
};
|
||||
|
||||
const onRemove = () =>
|
||||
confirm.show("Remove this campaign's placement monitor? Its past tests stay in Placement tests.", async () => {
|
||||
try {
|
||||
await remove.mutateAsync();
|
||||
toast.success("Placement monitor removed.");
|
||||
} catch (e) {
|
||||
toast.error(buildError(e as AppError));
|
||||
}
|
||||
});
|
||||
|
||||
const panels = overview.data?.panels ?? [];
|
||||
const panelOptions = (panels.length > 0 ? panels : [{ panel: "instance" as const, available: true, reason: "" }]).map((p) => ({
|
||||
value: p.panel,
|
||||
label: p.available ? PANEL_LABEL[p.panel] : `${PANEL_LABEL[p.panel]} (unavailable)`,
|
||||
disabled: !p.available && p.panel !== panel,
|
||||
}));
|
||||
const selectedPanel = panels.find((p) => p.panel === panel);
|
||||
|
||||
if (monitor.isLoading) {
|
||||
return (
|
||||
<div className="h-10 flex items-center">
|
||||
<Loader2Icon className="w-4 h-4 animate-spin text-slate-300" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const disabled = !canEdit || put.isPending;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{m?.last_error && (
|
||||
<div className="rounded-md border border-amber-100 bg-amber-50/70 px-3 py-2.5 flex gap-2.5">
|
||||
<AlertTriangleIcon className="w-4 h-4 text-amber-600 shrink-0 mt-0.5" />
|
||||
<p className="text-[11.5px] text-amber-900/90 leading-relaxed">The last scheduled test did not run: {m.last_error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SettingRow
|
||||
title="Placement monitor"
|
||||
description="Re-tests this campaign's first email every few days from one of its mailboxes, taking turns, and tells your team when too little of it reaches the inbox."
|
||||
control={
|
||||
<span title={!canEdit ? "You need the Send campaigns permission" : undefined} className="inline-flex items-center gap-2">
|
||||
{put.isPending && <Loader2Icon className="w-3.5 h-3.5 animate-spin text-slate-400" />}
|
||||
<Toggle id="campaign-pref-placement-monitor" value={enabled} onChange={toggle} disabled={disabled} />
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="rounded-md border border-slate-200 bg-slate-50/40 p-3.5 space-y-4">
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div>
|
||||
<Label>Test every</Label>
|
||||
<NumberInput
|
||||
value={intervalDays}
|
||||
min={PLACEMENT_MONITOR_INTERVAL_MIN}
|
||||
max={PLACEMENT_MONITOR_INTERVAL_MAX}
|
||||
onChange={setIntervalDays}
|
||||
onCommit={(v) => {
|
||||
if (v !== m?.interval_days) change({ interval_days: v });
|
||||
}}
|
||||
suffix="days"
|
||||
disabled={!canEdit}
|
||||
className="w-36"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Alert below inbox rate</Label>
|
||||
<NumberInput
|
||||
value={alertBelow}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={setAlertBelow}
|
||||
onCommit={(v) => {
|
||||
if (v !== m?.alert_below) change({ alert_below: v });
|
||||
}}
|
||||
suffix="%"
|
||||
disabled={!canEdit}
|
||||
className="w-36"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-[200px]">
|
||||
<Label>Seed panel</Label>
|
||||
<SelectMenu
|
||||
value={panel}
|
||||
onChange={(v) => {
|
||||
setPanel(v as PlacementPanel);
|
||||
change({ panel: v as PlacementPanel });
|
||||
}}
|
||||
options={panelOptions}
|
||||
disabled={!canEdit}
|
||||
fullWidth
|
||||
aria-label="Seed panel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{selectedPanel && !selectedPanel.available && (
|
||||
<p className="text-[11px] text-amber-600">{selectedPanel.reason || "This panel cannot run a test right now."}</p>
|
||||
)}
|
||||
{selectedPanel?.metered && (
|
||||
<p className="text-[11px] text-slate-500">Each scheduled test counts toward your monthly placement tests.</p>
|
||||
)}
|
||||
|
||||
<SettingRow
|
||||
title="Pause the campaign on an alert"
|
||||
description="Stops sending when a scheduled test falls below the alert rate. Starting the campaign again clears it."
|
||||
control={
|
||||
<Toggle
|
||||
value={pauseOnAlert}
|
||||
onChange={(v) => {
|
||||
setPauseOnAlert(v);
|
||||
change({ pause_on_alert: v });
|
||||
}}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{m && (
|
||||
<dl className="grid grid-cols-1 sm:grid-cols-3 gap-3 pt-1">
|
||||
<div>
|
||||
<dt className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Last run</dt>
|
||||
<dd className="mt-0.5 text-[12px] text-slate-700">
|
||||
{m.last_run_at ? fmtDate(m.last_run_at) : "Not yet"}
|
||||
{m.last_test_id && (
|
||||
<Link
|
||||
to={`/app/placement/${m.last_test_id}`}
|
||||
className="ml-1.5 inline-flex items-center gap-0.5 text-sky-700 hover:text-sky-800"
|
||||
>
|
||||
View
|
||||
<ArrowUpRightIcon className="w-3 h-3" />
|
||||
</Link>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Next run</dt>
|
||||
<dd className="mt-0.5 text-[12px] text-slate-700">{m.enabled ? fmtDate(m.next_run_at) : "Off"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Last alert</dt>
|
||||
<dd className="mt-0.5 text-[12px] text-slate-700">{m.last_alert_at ? fmtDate(m.last_alert_at) : "None"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
|
||||
<p className="text-[11px] text-slate-500 leading-relaxed">
|
||||
{m
|
||||
? "Changes save as you make them."
|
||||
: "Switch it on to start. The first test runs within a few minutes."}{" "}
|
||||
Every copy counts against the sending mailbox's daily limit.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Link
|
||||
to={`/app/placement?campaign_id=${campaignId}`}
|
||||
className="inline-flex items-center gap-1 text-[12px] text-sky-700 hover:text-sky-800"
|
||||
>
|
||||
This campaign's placement tests
|
||||
<ArrowUpRightIcon className="w-3 h-3" />
|
||||
</Link>
|
||||
{m && canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
disabled={remove.isPending}
|
||||
className="text-[12px] text-slate-500 hover:text-rose-600 transition-colors disabled:opacity-60"
|
||||
>
|
||||
Remove monitor
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,7 @@ import type { TemplatePreview } from "@/lib/api/client/app/campaigns/previewTemp
|
||||
import type Contact from "@/lib/api/models/app/contacts/Contact";
|
||||
import type Inbox from "@/lib/api/models/app/emails/Inbox";
|
||||
import formatBytes from "@/lib/helper/formatBytes";
|
||||
import { PreviewContactPicker, PreviewMailboxPicker, SendTestButton } from "./PreviewControls";
|
||||
import { PlacementTestButton, PreviewContactPicker, PreviewMailboxPicker, SendTestButton } from "./PreviewControls";
|
||||
import { SAMPLE_CONTACT_LABEL, contactLabel, useCampaignSenderInboxes } from "./previewContext";
|
||||
import { Label, TextInput } from "@/components/ui/field";
|
||||
import {
|
||||
@@ -333,7 +333,8 @@ export default function EmailContentEditor({
|
||||
<PreviewContactPicker campaignId={campaignId} value={previewContact} onChange={setPreviewContact} />
|
||||
<PreviewMailboxPicker inboxes={senders.inboxes} value={previewMailbox} onChange={setPreviewMailbox} />
|
||||
{stepId && canSendTest && (
|
||||
<div className="ml-auto">
|
||||
<div className="ml-auto flex flex-wrap items-center gap-1.5">
|
||||
<PlacementTestButton campaignId={campaignId} stepId={stepId} dirty={dirty} />
|
||||
<SendTestButton
|
||||
campaignId={campaignId}
|
||||
stepId={stepId}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// variants so every arm previews against the same lead and sender.
|
||||
|
||||
import React from "react";
|
||||
import { Loader2Icon, MailIcon, SendIcon, UserRoundIcon } from "lucide-react";
|
||||
import { Loader2Icon, MailCheckIcon, MailIcon, SendIcon, UserRoundIcon } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
import type Contact from "@/lib/api/models/app/contacts/Contact";
|
||||
import type Inbox from "@/lib/api/models/app/emails/Inbox";
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
} from "@/components/ui/popover-menu";
|
||||
import type { AppError } from "@/lib/api/client/normalizeError";
|
||||
import buildError from "@/lib/helper/buildError";
|
||||
import { usePermission, showPermissionDenied } from "@/hooks/usePermission";
|
||||
import NewPlacementTestDialog from "@/components/app/placement/tests/NewPlacementTestDialog";
|
||||
import { SAMPLE_CONTACT_LABEL, contactLabel } from "./previewContext";
|
||||
|
||||
// Picks the contact the preview renders for. Lists the campaign's own leads
|
||||
@@ -220,3 +222,36 @@ export function SendTestButton({
|
||||
</PopoverMenu>
|
||||
);
|
||||
}
|
||||
|
||||
// Opens a placement test of the saved step: the same copy sent to a panel of
|
||||
// seed inboxes to see whether it reaches the inbox, a tab or spam.
|
||||
export function PlacementTestButton({ campaignId, stepId, dirty }: { campaignId: string; stepId: string; dirty: boolean }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const canStart = usePermission("SEND_CAMPAIGNS");
|
||||
const onClick = () => {
|
||||
if (!canStart) {
|
||||
showPermissionDenied("SEND_CAMPAIGNS");
|
||||
return;
|
||||
}
|
||||
if (dirty) {
|
||||
toast.error("Save the step first so the test carries the latest copy.");
|
||||
return;
|
||||
}
|
||||
setOpen(true);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={dirty ? "Save the step first so the test carries the latest copy." : "Send this step to seed inboxes and see where it lands"}
|
||||
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 transition-colors hover:border-slate-300 hover:text-slate-900"
|
||||
>
|
||||
<MailCheckIcon className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">Test inbox placement</span>
|
||||
<span className="sm:hidden">Placement</span>
|
||||
</button>
|
||||
<NewPlacementTestDialog open={open} onClose={() => setOpen(false)} prefill={{ campaignId, stepId }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,766 @@
|
||||
// Starts an inbox placement test: one mailbox sends a campaign step or custom
|
||||
// copy to a panel of seed inboxes, one copy at a time, and the detail page
|
||||
// shows where each landed. Every refusal the server can give is shown beside
|
||||
// the part of the form it is about.
|
||||
|
||||
import React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
Loader2Icon,
|
||||
MailCheckIcon,
|
||||
MailIcon,
|
||||
MegaphoneIcon,
|
||||
PlayIcon,
|
||||
UserRoundIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
import { Label, SearchInput, TextInput } from "@/components/ui/field";
|
||||
import {
|
||||
PopoverMenu,
|
||||
PopoverMenuContent,
|
||||
PopoverMenuItem,
|
||||
PopoverMenuLabel,
|
||||
PopoverMenuSeparator,
|
||||
PopoverMenuTrigger,
|
||||
SelectButton,
|
||||
} from "@/components/ui/popover-menu";
|
||||
import { SelectMenu } from "@/components/ui/select-menu";
|
||||
import { OptionSelect, Segmented } from "@/components/app/campaigns/preferences/components/CampaignPreferenceBoolBox";
|
||||
import RichTextEditor from "@/components/app/campaigns/sequences/RichTextEditor";
|
||||
import { VARIABLES, htmlToPlain } from "@/components/app/campaigns/sequences/emailPreview";
|
||||
import { contactLabel } from "@/components/app/campaigns/sequences/previewContext";
|
||||
import { LINK_VARIABLES } from "@/lib/templateVars";
|
||||
import { useConfirm } from "@/hooks/context/confirm";
|
||||
import useDebouncedValue from "@/hooks/useDebouncedValue";
|
||||
import useCampaigns from "@/lib/api/hooks/app/campaigns/useCampaigns";
|
||||
import useCampaign from "@/lib/api/hooks/app/campaigns/useCampaign";
|
||||
import useCampaignSenders from "@/lib/api/hooks/app/campaigns/useCampaignSenders";
|
||||
import useSearchContacts from "@/lib/api/hooks/app/contacts/useSearchContacts";
|
||||
import getSequences from "@/lib/api/client/app/campaigns/sequences/getSequences";
|
||||
import { useCreatePlacementTest, usePlacementOverview, usePlacementSeeds } from "@/lib/api/hooks/app/placement/usePlacement";
|
||||
import {
|
||||
PANEL_LABEL,
|
||||
type CreatePlacementTestRequest,
|
||||
type PlacementPanel,
|
||||
type PlacementTracking,
|
||||
} from "@/lib/api/models/app/placement/Placement";
|
||||
import type Contact from "@/lib/api/models/app/contacts/Contact";
|
||||
import type { AppError } from "@/lib/api/client/normalizeError";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { placementErrorMessage, type PlacementErrorField } from "./placementTests";
|
||||
|
||||
type Source = "step" | "custom";
|
||||
|
||||
interface Draft {
|
||||
senderId: string;
|
||||
source: Source;
|
||||
campaignId: string;
|
||||
stepId: string;
|
||||
subject: string;
|
||||
bodyHtml: string;
|
||||
bodyPlain: string;
|
||||
bodyCode: boolean;
|
||||
contact: Contact | null;
|
||||
tracking: PlacementTracking;
|
||||
panel: PlacementPanel;
|
||||
}
|
||||
|
||||
export interface NewPlacementTestPrefill {
|
||||
campaignId?: string;
|
||||
stepId?: string;
|
||||
}
|
||||
|
||||
function emptyDraft(prefill?: NewPlacementTestPrefill): Draft {
|
||||
const fromStep = !!prefill?.campaignId;
|
||||
return {
|
||||
senderId: "",
|
||||
source: fromStep ? "step" : "custom",
|
||||
campaignId: prefill?.campaignId ?? "",
|
||||
stepId: prefill?.stepId ?? "",
|
||||
subject: "",
|
||||
bodyHtml: "",
|
||||
bodyPlain: "",
|
||||
bodyCode: false,
|
||||
contact: null,
|
||||
tracking: fromStep ? "campaign" : "off",
|
||||
panel: "instance",
|
||||
};
|
||||
}
|
||||
|
||||
// What the user typed or picked, for the discard prompt. The sender and panel
|
||||
// defaults the dialog fills in itself do not count.
|
||||
function draftKey(d: Draft): string {
|
||||
return JSON.stringify([d.source, d.campaignId, d.stepId, d.subject, d.bodyHtml, d.contact?.id ?? "", d.tracking]);
|
||||
}
|
||||
|
||||
function newKey(): string {
|
||||
return typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
export default function NewPlacementTestDialog({
|
||||
open,
|
||||
onClose,
|
||||
prefill,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
prefill?: NewPlacementTestPrefill;
|
||||
}) {
|
||||
if (typeof document === "undefined") return null;
|
||||
return createPortal(
|
||||
<AnimatePresence>{open && <DialogBody key="placement-dialog" onClose={onClose} prefill={prefill} />}</AnimatePresence>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function DialogBody({ onClose, prefill }: { onClose: () => void; prefill?: NewPlacementTestPrefill }) {
|
||||
const navigate = useNavigate();
|
||||
const confirm = useConfirm();
|
||||
const overview = usePlacementOverview();
|
||||
const seeds = usePlacementSeeds();
|
||||
const create = useCreatePlacementTest();
|
||||
|
||||
const [draft, setDraft] = React.useState<Draft>(() => emptyDraft(prefill));
|
||||
const initialKey = React.useRef(draftKey(emptyDraft(prefill)));
|
||||
const [error, setError] = React.useState<{ field: PlacementErrorField; message: string } | null>(null);
|
||||
const [nudged, setNudged] = React.useState(false);
|
||||
|
||||
// A retried submit of the same form lands on the tests the first one
|
||||
// started; any edit makes it a new request and clears the last refusal.
|
||||
const idemKey = React.useRef(newKey());
|
||||
const patch = (p: Partial<Draft>) => {
|
||||
idemKey.current = newKey();
|
||||
setError(null);
|
||||
setDraft((d) => ({ ...d, ...p }));
|
||||
};
|
||||
|
||||
const campaign = useCampaign(draft.source === "step" ? draft.campaignId : "");
|
||||
const campaignSenders = useCampaignSenders(draft.campaignId, draft.source === "step" && !!draft.campaignId);
|
||||
const steps = useQuery({
|
||||
queryKey: ["campaigns", draft.campaignId, "sequences"],
|
||||
queryFn: () => getSequences(draft.campaignId),
|
||||
enabled: draft.source === "step" && !!draft.campaignId,
|
||||
});
|
||||
const emailSteps = React.useMemo(
|
||||
() => (steps.data ?? []).filter((s) => (s.kind ?? "email") === "email"),
|
||||
[steps.data],
|
||||
);
|
||||
|
||||
// Senders: connected mailboxes that are not seeds, the campaign's own first.
|
||||
const inCampaign = React.useMemo(
|
||||
() => new Set((campaignSenders.data ?? []).filter((s) => s.enabled).map((s) => s.email_account_id)),
|
||||
[campaignSenders.data],
|
||||
);
|
||||
const senders = React.useMemo(() => {
|
||||
const list = (seeds.data ?? []).filter((m) => m.status === "active" && !m.seed);
|
||||
return [...list].sort((a, b) => Number(inCampaign.has(b.email_account_id)) - Number(inCampaign.has(a.email_account_id)));
|
||||
}, [seeds.data, inCampaign]);
|
||||
const sender = senders.find((s) => s.email_account_id === draft.senderId) ?? null;
|
||||
|
||||
// Default the sender to the campaign's first usable mailbox, else the first.
|
||||
React.useEffect(() => {
|
||||
if (sender || senders.length === 0) return;
|
||||
const pick = senders.find((s) => inCampaign.has(s.email_account_id)) ?? senders[0];
|
||||
setDraft((d) => ({ ...d, senderId: pick.email_account_id }));
|
||||
}, [sender, senders, inCampaign]);
|
||||
|
||||
// Default the step to the campaign's first email step.
|
||||
React.useEffect(() => {
|
||||
if (draft.source !== "step" || !draft.campaignId || emailSteps.length === 0) return;
|
||||
if (emailSteps.some((s) => s.id === draft.stepId)) return;
|
||||
setDraft((d) => ({ ...d, stepId: emailSteps[0].id }));
|
||||
}, [draft.source, draft.campaignId, draft.stepId, emailSteps]);
|
||||
|
||||
// Default the panel to the first one that can run a test.
|
||||
const panels = React.useMemo(() => overview.data?.panels ?? [], [overview.data]);
|
||||
const panel = panels.find((p) => p.panel === draft.panel);
|
||||
React.useEffect(() => {
|
||||
if (panels.length === 0 || panel?.available) return;
|
||||
const first = panels.find((p) => p.available);
|
||||
if (first) setDraft((d) => ({ ...d, panel: first.panel }));
|
||||
}, [panels, panel?.available]);
|
||||
|
||||
const textOnly = draft.source === "step" && !!campaign.data?.text_only;
|
||||
React.useEffect(() => {
|
||||
if (textOnly && (draft.tracking === "on" || draft.tracking === "compare")) {
|
||||
setDraft((d) => ({ ...d, tracking: "campaign" }));
|
||||
}
|
||||
}, [textOnly, draft.tracking]);
|
||||
|
||||
const compare = draft.tracking === "compare";
|
||||
const seedsPerTest = overview.data?.seeds_per_test ?? 0;
|
||||
const perTest = panel ? Math.min(panel.seeds, seedsPerTest || panel.seeds) : 0;
|
||||
const copies = perTest * (compare ? 2 : 1);
|
||||
const spacing = overview.data?.spacing_seconds ?? 20;
|
||||
const minutes = Math.max(1, Math.round((copies * spacing) / 60));
|
||||
const usage = overview.data?.usage;
|
||||
const testsNeeded = compare ? 2 : 1;
|
||||
const overQuota =
|
||||
!!panel?.metered && usage?.limit != null && usage.used + testsNeeded > usage.limit;
|
||||
|
||||
// Why the form cannot be sent yet, shown in the footer instead of a
|
||||
// silently disabled button.
|
||||
const issue: string | null = !sender
|
||||
? senders.length === 0 && !seeds.isLoading
|
||||
? "Connect a mailbox first. Seed inboxes cannot send a test."
|
||||
: "Pick the mailbox to send from."
|
||||
: draft.source === "step" && !draft.campaignId
|
||||
? "Pick a campaign."
|
||||
: draft.source === "step" && !draft.stepId
|
||||
? emailSteps.length === 0 && !steps.isLoading
|
||||
? "This campaign has no email step to test."
|
||||
: "Pick a step."
|
||||
: draft.source === "custom" && !draft.subject.trim()
|
||||
? "Write a subject."
|
||||
: draft.source === "custom" && !(draft.bodyPlain.trim() || (draft.bodyCode && draft.bodyHtml.trim()))
|
||||
? "Write the email body."
|
||||
: !panel || !panel.available
|
||||
? "Pick a panel that can run a test."
|
||||
: panel.seeds === 0
|
||||
? panel.panel === "workspace"
|
||||
? "You have no seed inboxes yet. Mark one on the Seed inboxes tab of Placement tests."
|
||||
: "This panel has no seed inboxes yet."
|
||||
: overQuota
|
||||
? `This month's tests are used up${compare && usage && usage.limit != null && usage.used < usage.limit ? " (a comparison counts as 2)" : ""}. Your own seed inboxes are never counted.`
|
||||
: null;
|
||||
|
||||
const dirty = draftKey(draft) !== initialKey.current;
|
||||
const pending = create.isPending;
|
||||
|
||||
const requestClose = React.useCallback(() => {
|
||||
if (pending) return;
|
||||
if (dirty) {
|
||||
confirm.show("Discard this placement test?", async () => onClose());
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
}, [pending, dirty, confirm, onClose]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape") return;
|
||||
// An open picker or the discard confirm owns this Escape.
|
||||
if (document.querySelector("[data-floating], [role='alertdialog']")) return;
|
||||
e.preventDefault();
|
||||
requestClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [requestClose]);
|
||||
|
||||
async function submit() {
|
||||
if (pending) return;
|
||||
if (issue) {
|
||||
setNudged(true);
|
||||
return;
|
||||
}
|
||||
const body: CreatePlacementTestRequest = {
|
||||
sender_account_id: draft.senderId,
|
||||
tracking: draft.tracking,
|
||||
panel: draft.panel,
|
||||
...(draft.contact ? { contact_id: draft.contact.id } : {}),
|
||||
};
|
||||
if (draft.source === "step") {
|
||||
body.campaign_id = draft.campaignId;
|
||||
body.sequence_id = draft.stepId;
|
||||
} else {
|
||||
body.subject = draft.subject.trim();
|
||||
body.body_html = draft.bodyHtml;
|
||||
body.body_plain = draft.bodyCode ? htmlToPlain(draft.bodyHtml) : draft.bodyPlain;
|
||||
}
|
||||
try {
|
||||
const tests = await create.mutateAsync({ body, idempotencyKey: idemKey.current });
|
||||
toast.success(tests.length > 1 ? "Comparison started." : "Placement test started.");
|
||||
onClose();
|
||||
if (tests[0]) navigate(`/app/placement/${tests[0].id}`);
|
||||
} catch (err) {
|
||||
setError(placementErrorMessage(err as AppError, { resetsOn: usage?.period_end, panel: draft.panel }));
|
||||
}
|
||||
}
|
||||
|
||||
const fieldError = (f: PlacementErrorField) =>
|
||||
error?.field === f ? <InlineError message={error.message} /> : null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
onMouseDown={requestClose}
|
||||
className="fixed inset-0 z-[110] flex items-center justify-center bg-slate-900/30 backdrop-blur-[2px] px-4"
|
||||
>
|
||||
<motion.div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="New placement test"
|
||||
initial={{ y: 8, opacity: 0, scale: 0.985 }}
|
||||
animate={{ y: 0, opacity: 1, scale: 1 }}
|
||||
exit={{ y: 8, opacity: 0, scale: 0.985 }}
|
||||
transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="w-full max-w-[680px] rounded-lg bg-white border border-slate-200 shadow-[0_24px_48px_-12px_rgba(15,23,42,0.18),0_8px_16px_-8px_rgba(15,23,42,0.1)] overflow-hidden flex flex-col max-h-[88dvh]"
|
||||
>
|
||||
<div className="h-12 px-4 border-b border-slate-200 flex items-center gap-2.5 shrink-0">
|
||||
<div className="size-5 rounded bg-slate-100 text-slate-600 flex items-center justify-center">
|
||||
<MailCheckIcon className="w-3 h-3" />
|
||||
</div>
|
||||
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">New</span>
|
||||
<div className="h-4 w-px bg-slate-200" />
|
||||
<span className="text-[12.5px] text-slate-900 font-medium">Placement test</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={requestClose}
|
||||
aria-label="Close"
|
||||
className="ml-auto size-7 rounded-md text-slate-500 hover:text-slate-900 hover:bg-slate-100 inline-flex items-center justify-center transition-colors"
|
||||
>
|
||||
<XIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-5 py-5 space-y-6">
|
||||
{/* Sender */}
|
||||
<section>
|
||||
<Label>Send from</Label>
|
||||
<SenderPicker
|
||||
senders={senders}
|
||||
inCampaign={inCampaign}
|
||||
value={draft.senderId}
|
||||
loading={seeds.isLoading}
|
||||
onChange={(id) => patch({ senderId: id })}
|
||||
/>
|
||||
<p className="mt-1.5 text-[11px] text-slate-400 leading-relaxed">
|
||||
Only connected mailboxes can send. Seed inboxes are left out.
|
||||
</p>
|
||||
{fieldError("sender")}
|
||||
</section>
|
||||
|
||||
{/* What to test */}
|
||||
<section className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">What to test</span>
|
||||
<Segmented<Source>
|
||||
value={draft.source}
|
||||
onChange={(v) =>
|
||||
patch({
|
||||
source: v,
|
||||
tracking: v === "step" ? "campaign" : draft.tracking === "campaign" ? "off" : draft.tracking,
|
||||
})
|
||||
}
|
||||
options={[
|
||||
{ value: "step", label: "Campaign step" },
|
||||
{ value: "custom", label: "Custom copy" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{draft.source === "step" ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="min-w-0">
|
||||
<Label>Campaign</Label>
|
||||
<CampaignPicker
|
||||
value={draft.campaignId}
|
||||
name={campaign.data?.name}
|
||||
onChange={(id) => patch({ campaignId: id, stepId: "", contact: null })}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<Label>Step</Label>
|
||||
<SelectMenu
|
||||
value={draft.stepId}
|
||||
onChange={(v) => patch({ stepId: v })}
|
||||
disabled={!draft.campaignId || steps.isLoading}
|
||||
fullWidth
|
||||
placeholder={
|
||||
!draft.campaignId
|
||||
? "Pick a campaign first"
|
||||
: steps.isLoading
|
||||
? "Loading steps…"
|
||||
: emailSteps.length === 0
|
||||
? "No email steps"
|
||||
: "Pick a step"
|
||||
}
|
||||
options={emailSteps.map((s, i) => ({
|
||||
value: s.id,
|
||||
label: `${s.name || `Step ${i + 1}`}${s.subject ? `: ${s.subject}` : ""}`,
|
||||
}))}
|
||||
aria-label="Step"
|
||||
/>
|
||||
</div>
|
||||
<p className="sm:col-span-2 text-[11px] text-slate-400 leading-relaxed">
|
||||
The saved step is rendered exactly as the campaign sends it: merge fields, spintax,
|
||||
signature, opt-out footer and unsubscribe header.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>Subject</Label>
|
||||
<TextInput
|
||||
value={draft.subject}
|
||||
onChange={(v) => patch({ subject: v })}
|
||||
placeholder="Quick question, {{.FirstName}}"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Body</Label>
|
||||
<RichTextEditor
|
||||
html={draft.bodyHtml}
|
||||
onChange={(html) =>
|
||||
patch({ bodyHtml: html, bodyPlain: draft.bodyCode ? "" : htmlToPlain(html) })
|
||||
}
|
||||
code={draft.bodyCode}
|
||||
onCodeChange={(c) => patch({ bodyCode: c })}
|
||||
variables={VARIABLES}
|
||||
links={LINK_VARIABLES}
|
||||
placeholder="Hi {{.FirstName}}, …"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{fieldError("source")}
|
||||
|
||||
<div>
|
||||
<Label>Render for</Label>
|
||||
<ContactPicker
|
||||
campaignId={draft.source === "step" ? draft.campaignId : ""}
|
||||
value={draft.contact}
|
||||
onChange={(c) => patch({ contact: c })}
|
||||
/>
|
||||
<p className="mt-1.5 text-[11px] text-slate-400 leading-relaxed">
|
||||
Fills the merge fields. Nobody but the seed inboxes receives the copies.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Tracking */}
|
||||
<section>
|
||||
<span className="block mb-2 text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Tracking</span>
|
||||
<OptionSelect<PlacementTracking>
|
||||
value={draft.tracking}
|
||||
onChange={(v) => patch({ tracking: v })}
|
||||
cols={2}
|
||||
aria-label="Tracking"
|
||||
options={[
|
||||
...(draft.source === "step"
|
||||
? [{ value: "campaign" as const, label: "As the campaign", hint: "Uses the campaign's open and click tracking." }]
|
||||
: []),
|
||||
...(textOnly
|
||||
? []
|
||||
: [{ value: "on" as const, label: "On", hint: "Open pixel and tracked links." }]),
|
||||
{ value: "off" as const, label: "Off", hint: "No pixel, links left as written." },
|
||||
...(textOnly
|
||||
? []
|
||||
: [
|
||||
{
|
||||
value: "compare" as const,
|
||||
label: "Compare with and without",
|
||||
hint: "Two tests to the same seeds. Counts as 2 tests.",
|
||||
},
|
||||
]),
|
||||
]}
|
||||
/>
|
||||
{textOnly && (
|
||||
<p className="mt-1.5 text-[11px] text-slate-400">This campaign sends plain text, which carries no tracking.</p>
|
||||
)}
|
||||
{fieldError("tracking")}
|
||||
</section>
|
||||
|
||||
{/* Panel */}
|
||||
<section>
|
||||
<span className="block mb-2 text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Seed panel</span>
|
||||
{overview.isLoading ? (
|
||||
<div className="h-16 rounded-md bg-slate-50 animate-pulse" />
|
||||
) : (
|
||||
<div role="radiogroup" aria-label="Seed panel" className="grid gap-1.5">
|
||||
{panels.map((p) => {
|
||||
const active = p.panel === draft.panel;
|
||||
return (
|
||||
<button
|
||||
key={p.panel}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
disabled={!p.available}
|
||||
onClick={() => patch({ panel: p.panel })}
|
||||
className={cn(
|
||||
"flex w-full items-start gap-2.5 rounded-md border px-3 py-2 text-left transition-colors outline-none focus-visible:ring-2 focus-visible:ring-sky-100",
|
||||
active && p.available
|
||||
? "border-sky-300 bg-sky-50"
|
||||
: "border-slate-200 bg-white hover:border-slate-300 hover:bg-slate-50",
|
||||
!p.available && "opacity-60 cursor-not-allowed hover:bg-white hover:border-slate-200",
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className={cn("text-[12px] font-medium", active && p.available ? "text-sky-700" : "text-slate-700")}>
|
||||
{PANEL_LABEL[p.panel]}
|
||||
</span>
|
||||
<span className="font-mono text-[10.5px] text-slate-400 tabular-nums">
|
||||
{p.seeds} seed{p.seeds === 1 ? "" : "s"}
|
||||
</span>
|
||||
{p.metered && p.available && (
|
||||
<span className="h-4 px-1.5 rounded bg-slate-100 text-[10px] text-slate-500 inline-flex items-center">
|
||||
Counted
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[11px] leading-snug text-slate-400">
|
||||
{!p.available
|
||||
? p.reason || "Not available on this workspace."
|
||||
: p.metered
|
||||
? usage?.limit != null
|
||||
? `Counts toward your monthly tests: ${usage.used} of ${usage.limit} used.`
|
||||
: "Counts toward your monthly tests."
|
||||
: "Not counted toward your monthly tests."}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 size-4 shrink-0 rounded-full border transition-colors",
|
||||
active && p.available ? "border-sky-600 bg-sky-600 ring-2 ring-inset ring-white" : "border-slate-300 bg-white",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{fieldError("panel")}
|
||||
</section>
|
||||
|
||||
{/* Cost */}
|
||||
{sender && panel?.available && copies > 0 && (
|
||||
<div className="rounded-md border border-slate-200 bg-slate-50/60 px-3 py-2.5 text-[11.5px] leading-relaxed text-slate-600">
|
||||
Sends up to <b className="font-medium text-slate-900">{copies}</b> email{copies === 1 ? "" : "s"} from{" "}
|
||||
<b className="font-medium text-slate-900">{sender.email}</b>, one every ~{spacing} seconds, counted
|
||||
against its daily limit. Sending takes about {minutes} minute{minutes === 1 ? "" : "s"}; a copy
|
||||
not seen within 2 hours counts as never arrived. Seeds on the sender's own domain are skipped.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-t border-slate-200 px-4 py-3 flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
{error?.field === "general" ? (
|
||||
<InlineError message={error.message} compact />
|
||||
) : nudged && issue ? (
|
||||
<InlineError message={issue} compact />
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={requestClose}
|
||||
disabled={pending}
|
||||
className="h-7 px-3 text-[12px] font-medium text-slate-600 hover:text-slate-900 border border-slate-200 hover:border-slate-300 rounded-md transition-colors disabled:opacity-60"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={pending}
|
||||
title={issue ?? undefined}
|
||||
className={cn(
|
||||
"h-7 px-3 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-60",
|
||||
issue && "opacity-60",
|
||||
)}
|
||||
>
|
||||
{pending ? <Loader2Icon className="w-3.5 h-3.5 animate-spin" /> : <PlayIcon className="w-3.5 h-3.5" />}
|
||||
{compare ? "Start comparison" : "Start test"}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineError({ message, compact = false }: { message: string; compact?: boolean }) {
|
||||
return (
|
||||
<p className={cn("flex items-start gap-1.5 text-[11.5px] leading-snug text-rose-600", !compact && "mt-1.5")}>
|
||||
<AlertCircleIcon className="w-3.5 h-3.5 shrink-0 mt-px" />
|
||||
<span>{message}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function SenderPicker({
|
||||
senders,
|
||||
inCampaign,
|
||||
value,
|
||||
loading,
|
||||
onChange,
|
||||
}: {
|
||||
senders: { email_account_id: string; email: string; label: string }[];
|
||||
inCampaign: Set<string>;
|
||||
value: string;
|
||||
loading: boolean;
|
||||
onChange: (id: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [q, setQ] = React.useState("");
|
||||
const current = senders.find((s) => s.email_account_id === value);
|
||||
const needle = q.trim().toLowerCase();
|
||||
const shown = needle ? senders.filter((s) => s.email.toLowerCase().includes(needle)) : senders;
|
||||
const campaignRows = shown.filter((s) => inCampaign.has(s.email_account_id));
|
||||
const otherRows = shown.filter((s) => !inCampaign.has(s.email_account_id));
|
||||
|
||||
const row = (s: (typeof senders)[number]) => (
|
||||
<PopoverMenuItem key={s.email_account_id} selected={s.email_account_id === value} onSelect={() => onChange(s.email_account_id)}>
|
||||
<span className="text-slate-800">{s.email}</span>
|
||||
{s.label && <span className="ml-1.5 text-[11px] text-slate-400">{s.label}</span>}
|
||||
</PopoverMenuItem>
|
||||
);
|
||||
|
||||
return (
|
||||
<PopoverMenu open={open} onOpenChange={setOpen}>
|
||||
<PopoverMenuTrigger asChild>
|
||||
<SelectButton
|
||||
icon={<MailIcon className="w-3.5 h-3.5" />}
|
||||
label={current ? current.email : loading ? "Loading mailboxes…" : "Pick a mailbox"}
|
||||
className="w-full [&>span:nth-child(2)]:max-w-none [&>span:nth-child(2)]:flex-1 [&>span:nth-child(2)]:text-left"
|
||||
/>
|
||||
</PopoverMenuTrigger>
|
||||
<PopoverMenuContent minWidth={300} matchTriggerWidth className="p-1 max-h-80">
|
||||
<div className="p-1.5">
|
||||
<SearchInput value={q} onChange={setQ} placeholder="Search mailboxes…" autoFocus />
|
||||
</div>
|
||||
{shown.length === 0 ? (
|
||||
<div className="px-3 py-2 text-[11.5px] text-slate-400">
|
||||
{senders.length === 0 ? "No connected mailbox can send a test." : "No mailbox matches that."}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{campaignRows.length > 0 && (
|
||||
<>
|
||||
<PopoverMenuLabel>In this campaign</PopoverMenuLabel>
|
||||
{campaignRows.map(row)}
|
||||
{otherRows.length > 0 && <PopoverMenuSeparator />}
|
||||
</>
|
||||
)}
|
||||
{otherRows.length > 0 && (
|
||||
<>
|
||||
{campaignRows.length > 0 && <PopoverMenuLabel>Other mailboxes</PopoverMenuLabel>}
|
||||
{otherRows.map(row)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PopoverMenuContent>
|
||||
</PopoverMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function CampaignPicker({ value, name, onChange }: { value: string; name?: string; onChange: (id: string) => void }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [q, setQ] = React.useState("");
|
||||
const debounced = useDebouncedValue(q.trim(), 250);
|
||||
const list = useCampaigns({ query: debounced, folder: "", limit: 20, enabled: open });
|
||||
return (
|
||||
<PopoverMenu open={open} onOpenChange={setOpen}>
|
||||
<PopoverMenuTrigger asChild>
|
||||
<SelectButton
|
||||
icon={<MegaphoneIcon className="w-3.5 h-3.5" />}
|
||||
label={value ? (name ?? "Loading…") : "Pick a campaign"}
|
||||
className="w-full [&>span:nth-child(2)]:max-w-none [&>span:nth-child(2)]:flex-1 [&>span:nth-child(2)]:text-left"
|
||||
/>
|
||||
</PopoverMenuTrigger>
|
||||
<PopoverMenuContent minWidth={280} className="p-1 max-h-80">
|
||||
<div className="p-1.5">
|
||||
<SearchInput value={q} onChange={setQ} placeholder="Search campaigns…" autoFocus />
|
||||
</div>
|
||||
{list.isLoading && list.campaigns.length === 0 ? (
|
||||
<div className="px-3 py-2 text-[11.5px] text-slate-400 inline-flex items-center gap-1.5">
|
||||
<Loader2Icon className="w-3 h-3 animate-spin" /> Loading…
|
||||
</div>
|
||||
) : list.campaigns.length === 0 ? (
|
||||
<div className="px-3 py-2 text-[11.5px] text-slate-400">No campaign matches that.</div>
|
||||
) : (
|
||||
list.campaigns.map((c) => (
|
||||
<PopoverMenuItem key={c.id} selected={c.id === value} onSelect={() => onChange(c.id)}>
|
||||
{c.name}
|
||||
</PopoverMenuItem>
|
||||
))
|
||||
)}
|
||||
</PopoverMenuContent>
|
||||
</PopoverMenu>
|
||||
);
|
||||
}
|
||||
|
||||
// Whose merge fields fill the copy. Empty = the campaign's first lead, or the
|
||||
// built-in sample contact for custom copy.
|
||||
function ContactPicker({
|
||||
campaignId,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
campaignId: string;
|
||||
value: Contact | null;
|
||||
onChange: (c: Contact | null) => void;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [q, setQ] = React.useState("");
|
||||
const debounced = useDebouncedValue(q.trim(), 250);
|
||||
const searching = debounced.length > 0;
|
||||
const search = useSearchContacts({
|
||||
options: {
|
||||
query: debounced,
|
||||
custom_field_filters: [],
|
||||
campaign_ids: searching || !campaignId ? [] : [campaignId],
|
||||
sort_by: "updated_at",
|
||||
reverse: false,
|
||||
},
|
||||
limit: 8,
|
||||
enabled: open,
|
||||
keepPrevious: true,
|
||||
});
|
||||
const contacts = search.contacts ?? [];
|
||||
const fallback = campaignId ? "The campaign's first lead" : "A sample contact";
|
||||
return (
|
||||
<PopoverMenu open={open} onOpenChange={setOpen}>
|
||||
<PopoverMenuTrigger asChild>
|
||||
<SelectButton
|
||||
icon={<UserRoundIcon className="w-3.5 h-3.5" />}
|
||||
label={value ? contactLabel(value) : fallback}
|
||||
className="w-full [&>span:nth-child(2)]:max-w-none [&>span:nth-child(2)]:flex-1 [&>span:nth-child(2)]:text-left"
|
||||
/>
|
||||
</PopoverMenuTrigger>
|
||||
<PopoverMenuContent minWidth={300} matchTriggerWidth className="p-1">
|
||||
<div className="p-1.5">
|
||||
<SearchInput value={q} onChange={setQ} placeholder="Search contacts…" autoFocus />
|
||||
</div>
|
||||
<PopoverMenuItem selected={value === null} onSelect={() => onChange(null)} icon={<UserRoundIcon className="w-3.5 h-3.5" />}>
|
||||
{fallback}
|
||||
</PopoverMenuItem>
|
||||
<PopoverMenuSeparator />
|
||||
<PopoverMenuLabel>{searching || !campaignId ? "Contacts" : "Leads in this campaign"}</PopoverMenuLabel>
|
||||
<div className="max-h-56 overflow-y-auto">
|
||||
{search.isLoading && contacts.length === 0 ? (
|
||||
<div className="px-3 py-2 text-[11.5px] text-slate-400 inline-flex items-center gap-1.5">
|
||||
<Loader2Icon className="w-3 h-3 animate-spin" /> Loading…
|
||||
</div>
|
||||
) : contacts.length === 0 ? (
|
||||
<div className="px-3 py-2 text-[11.5px] text-slate-400">
|
||||
{searching ? "No contact matches that." : "No contacts yet. Type to search."}
|
||||
</div>
|
||||
) : (
|
||||
contacts.map((c) => (
|
||||
<PopoverMenuItem key={c.id} selected={value?.id === c.id} onSelect={() => onChange(c)}>
|
||||
<span className="text-slate-800">{contactLabel(c)}</span>
|
||||
<span className="ml-1.5 text-[11px] text-slate-400">{c.email}</span>
|
||||
</PopoverMenuItem>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</PopoverMenuContent>
|
||||
</PopoverMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// Small presentational pieces shared by the placement test list, detail and
|
||||
// campaign surfaces.
|
||||
import React from "react";
|
||||
import { EyeIcon, EyeOffIcon, InfoIcon, SplitIcon } from "lucide-react";
|
||||
import { DitherStack } from "@/components/ui/dither";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
PlacementCounts,
|
||||
PlacementFolder,
|
||||
PlacementOverview,
|
||||
PlacementTest,
|
||||
PlacementTestStatus,
|
||||
} from "@/lib/api/models/app/placement/Placement";
|
||||
import { PANEL_HINT, PANEL_LABEL } from "@/lib/api/models/app/placement/Placement";
|
||||
import { FOLDER, LANDED_FOLDERS, STATUS, isTracked } from "./placementTests";
|
||||
|
||||
export function StatusChip({ status, counts }: { status: PlacementTestStatus; counts?: PlacementCounts }) {
|
||||
const s = STATUS[status] ?? STATUS.failed;
|
||||
const progress = status === "running" && counts ? ` ${Math.max(0, counts.total - counts.pending)}/${counts.total}` : "";
|
||||
return (
|
||||
<span className={cn("inline-flex items-center gap-1.5 h-5 px-1.5 rounded-md border text-[10.5px] font-medium whitespace-nowrap", s.chip)}>
|
||||
<span className={cn("size-1.5 rounded-full", s.dot)} />
|
||||
{s.label}
|
||||
{progress && <span className="font-mono tabular-nums">{progress}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrackingBadge({ test }: { test: Pick<PlacementTest, "open_tracking" | "link_tracking" | "compare_group_id"> }) {
|
||||
const tracked = isTracked(test);
|
||||
const Icon = tracked ? EyeIcon : EyeOffIcon;
|
||||
const parts = [test.open_tracking && "opens", test.link_tracking && "clicks"].filter(Boolean).join(" and ");
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span
|
||||
title={tracked ? `Tracks ${parts}` : "No open pixel or tracked links"}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 h-5 px-1.5 rounded-md text-[10.5px] font-medium whitespace-nowrap",
|
||||
tracked ? "bg-amber-50 text-amber-700" : "bg-slate-100 text-slate-600",
|
||||
)}
|
||||
>
|
||||
<Icon className="w-3 h-3" />
|
||||
{tracked ? "Tracked" : "Untracked"}
|
||||
</span>
|
||||
{test.compare_group_id && (
|
||||
<span
|
||||
title="Half of a with and without tracking comparison"
|
||||
className="inline-flex items-center gap-1 h-5 px-1.5 rounded-md bg-sky-50 text-sky-700 text-[10.5px] font-medium"
|
||||
>
|
||||
<SplitIcon className="w-3 h-3" />
|
||||
<span className="hidden sm:inline">Compare</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function FolderChip({ folder }: { folder: PlacementFolder }) {
|
||||
const f = FOLDER[folder] ?? FOLDER.pending;
|
||||
return (
|
||||
<span className={cn("inline-flex items-center gap-1.5 h-5 px-1.5 rounded-md border text-[10.5px] font-medium whitespace-nowrap", f.chip)}>
|
||||
<span className={cn("size-1.5 rounded-full", f.dot)} />
|
||||
{f.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Inbox, tabs, spam and never arrived as shares of every copy in the test, so
|
||||
// the part still waiting shows as the empty track.
|
||||
export function PlacementBar({ counts, height = 6, className }: { counts: PlacementCounts; height?: number; className?: string }) {
|
||||
const { total, inbox, promotions, other, spam, missing } = counts;
|
||||
const segments = React.useMemo(() => {
|
||||
const denom = Math.max(1, total);
|
||||
const n: Record<(typeof LANDED_FOLDERS)[number], number> = { inbox, promotions, other, spam, missing };
|
||||
return LANDED_FOLDERS.filter((f) => n[f] > 0).map((f) => ({ frac: n[f] / denom, tone: FOLDER[f].tone }));
|
||||
}, [total, inbox, promotions, other, spam, missing]);
|
||||
const title = LANDED_FOLDERS.map((f) => `${FOLDER[f].label} ${counts[f]}`).join(", ");
|
||||
return (
|
||||
<div title={title} className={className}>
|
||||
<DitherStack segments={segments} height={height} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlacementLegend({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn("flex flex-wrap items-center gap-x-3 gap-y-1", className)}>
|
||||
{LANDED_FOLDERS.map((f) => (
|
||||
<span key={f} className="inline-flex items-center gap-1.5 text-[11px] text-slate-500">
|
||||
<span className={cn("size-1.5 rounded-full", FOLDER[f].dot)} />
|
||||
{FOLDER[f].label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Seed results are a signal about the copy and the sender, not a forecast.
|
||||
export function PlacementCaveat({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn("flex gap-2 rounded-md border border-slate-200 bg-slate-50/60 px-3 py-2.5", className)}>
|
||||
<InfoIcon className="w-3.5 h-3.5 text-slate-400 shrink-0 mt-0.5" />
|
||||
<p className="text-[11.5px] leading-relaxed text-slate-500">
|
||||
Seed results are a signal, not a forecast. One test is noise, so look for the same result across a few.
|
||||
Seed inboxes have no history with your sender and do not sit behind corporate filters, so real
|
||||
recipients can see something different.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// One card per seed panel: whether it can run a test, how many seeds it has
|
||||
// and which providers they cover.
|
||||
export function PanelStrip({ overview }: { overview: PlacementOverview }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-1 border-b border-slate-200",
|
||||
overview.panels.length >= 3 ? "md:grid-cols-3" : overview.panels.length === 2 ? "md:grid-cols-2" : "",
|
||||
)}
|
||||
>
|
||||
{overview.panels.map((p, i) => {
|
||||
const families = p.families ?? [];
|
||||
return (
|
||||
<div
|
||||
key={p.panel}
|
||||
className={cn(
|
||||
"px-5 py-3 min-w-0",
|
||||
i < overview.panels.length - 1 && "border-b md:border-b-0 md:border-r border-slate-200",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("size-1.5 rounded-full shrink-0", p.available ? "bg-emerald-500" : "bg-slate-300")} />
|
||||
<span className="text-[12.5px] font-medium text-slate-900 truncate">{PANEL_LABEL[p.panel]}</span>
|
||||
<span className="ml-auto font-mono text-[11px] text-slate-500 tabular-nums shrink-0">
|
||||
{p.seeds} seed{p.seeds === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 text-[11px] text-slate-400 leading-relaxed">
|
||||
{p.available ? PANEL_HINT[p.panel] : p.reason || "Not available on this workspace."}
|
||||
{p.available && (p.metered ? " Counts toward your monthly tests." : " Not counted toward your monthly tests.")}
|
||||
</p>
|
||||
{families.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{families.map((f) => (
|
||||
<span
|
||||
key={f.family}
|
||||
className="inline-flex items-center gap-1 h-5 px-1.5 rounded bg-slate-100 text-[10.5px] text-slate-600"
|
||||
>
|
||||
{f.label}
|
||||
<span className="font-mono text-slate-400 tabular-nums">{f.seeds}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// The workspace's own seed panel: any connected mailbox can be marked as a
|
||||
// seed inbox. Tests on this panel are never counted toward the monthly
|
||||
// allowance, and a seed never warms up or sends campaign mail.
|
||||
|
||||
import React from "react";
|
||||
import { InboxIcon, Loader2Icon } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Toggle } from "@/components/app/campaigns/preferences/components/CampaignPreferenceBoolBox";
|
||||
import { EmptyBlock, SectionBar } from "@/components/layout/Page";
|
||||
import { SearchInput } from "@/components/ui/field";
|
||||
import { useConfirm } from "@/hooks/context/confirm";
|
||||
import { usePermission } from "@/hooks/usePermission";
|
||||
import { usePlacementSeeds, useSetPlacementSeed } from "@/lib/api/hooks/app/placement/usePlacement";
|
||||
import type { PlacementWorkspaceSeed } from "@/lib/api/models/app/placement/Placement";
|
||||
import type { AppError } from "@/lib/api/client/normalizeError";
|
||||
import buildError from "@/lib/helper/buildError";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
active: "Connected",
|
||||
inactive: "Off",
|
||||
revoked: "Needs reconnecting",
|
||||
};
|
||||
|
||||
export default function SeedInboxes() {
|
||||
const seeds = usePlacementSeeds();
|
||||
const canManage = usePermission("MANAGE_EMAILS");
|
||||
const [q, setQ] = React.useState("");
|
||||
const rows = seeds.data ?? [];
|
||||
const needle = q.trim().toLowerCase();
|
||||
const shown = needle ? rows.filter((r) => r.email.toLowerCase().includes(needle) || r.label.toLowerCase().includes(needle)) : rows;
|
||||
// Seeds first, so the panel reads at a glance.
|
||||
const sorted = [...shown].sort((a, b) => Number(b.seed) - Number(a.seed) || a.email.localeCompare(b.email));
|
||||
const seedCount = rows.filter((r) => r.seed).length;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="px-5 py-4 border-b border-slate-200/60 max-w-3xl">
|
||||
<p className="text-[12.5px] text-slate-700 leading-relaxed">
|
||||
A seed inbox receives test copies so you can see where they land. Tests on your own seeds are never
|
||||
counted toward your monthly tests.
|
||||
</p>
|
||||
<p className="mt-1.5 text-[11.5px] text-slate-500 leading-relaxed">
|
||||
A seed never warms up and never sends campaign mail, so use a test mailbox, not one you send from.
|
||||
It should be on a different domain than your senders: a personal Gmail, Outlook.com or Yahoo address
|
||||
works best. Seeds on a sender's own domain are skipped, because mail inside one domain is not
|
||||
filtered like mail from outside.
|
||||
</p>
|
||||
</div>
|
||||
<SectionBar label="Mailboxes" count={seeds.data ? `${seedCount} seed${seedCount === 1 ? "" : "s"}` : undefined}>
|
||||
<div className="w-full sm:w-56">
|
||||
<SearchInput value={q} onChange={setQ} placeholder="Search mailboxes…" />
|
||||
</div>
|
||||
</SectionBar>
|
||||
{seeds.isLoading ? (
|
||||
<div className="divide-y divide-slate-200/60">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="h-12 px-5 flex items-center">
|
||||
<div className="h-3 w-56 rounded bg-slate-100 animate-pulse" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : seeds.isError ? (
|
||||
<EmptyBlock title="Seed inboxes could not be loaded" body={buildError(seeds.error as unknown as AppError)} />
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyBlock
|
||||
title="No mailboxes yet"
|
||||
body="Connect a test mailbox, then mark it as a seed here."
|
||||
cta={
|
||||
<Link
|
||||
to="/app/emails"
|
||||
className="h-7 px-2.5 rounded-md inline-flex items-center gap-1.5 text-[12px] font-medium bg-sky-600 hover:bg-sky-700 text-white transition-colors"
|
||||
>
|
||||
Connect a mailbox
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
) : sorted.length === 0 ? (
|
||||
<EmptyBlock title="No mailbox matches that" />
|
||||
) : (
|
||||
<div className="divide-y divide-slate-200/60">
|
||||
{sorted.map((r) => (
|
||||
<SeedRow key={r.email_account_id} row={r} canManage={canManage} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SeedRow({ row, canManage }: { row: PlacementWorkspaceSeed; canManage: boolean }) {
|
||||
const set = useSetPlacementSeed();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const apply = async (seed: boolean) => {
|
||||
try {
|
||||
await set.mutateAsync({ emailAccountId: row.email_account_id, seed });
|
||||
toast.success(seed ? `${row.email} is now a seed inbox.` : `${row.email} is no longer a seed inbox.`);
|
||||
} catch (e) {
|
||||
const err = e as AppError;
|
||||
toast.error(
|
||||
err?.code === "placement_seed_limit"
|
||||
? "A workspace can have up to 50 seed inboxes."
|
||||
: err?.code === "placement_seed_unavailable"
|
||||
? err.message || "This mailbox cannot be changed right now."
|
||||
: buildError(err),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Turning a seed on stops its warmup, so it is confirmed; turning it off is harmless.
|
||||
const toggle = (seed: boolean) => {
|
||||
if (!seed) {
|
||||
void apply(false);
|
||||
return;
|
||||
}
|
||||
confirm.show(
|
||||
`Make ${row.email} a seed inbox? Its warmup turns off and it stops sending campaign mail while it is a seed.`,
|
||||
() => apply(true),
|
||||
);
|
||||
};
|
||||
|
||||
const blocked = !!row.blocker;
|
||||
const disabled = !canManage || blocked || set.isPending;
|
||||
|
||||
return (
|
||||
<div className="min-h-12 px-5 py-2 flex items-center gap-3">
|
||||
<InboxIcon className={cn("w-3.5 h-3.5 shrink-0", row.seed ? "text-sky-600" : "text-slate-300")} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
|
||||
<span className="text-[12.5px] font-medium text-slate-900 truncate max-w-full">{row.email}</span>
|
||||
{row.label && <span className="text-[11px] text-slate-400">{row.label}</span>}
|
||||
{row.status !== "active" && (
|
||||
<span className="h-4 px-1.5 rounded bg-amber-50 text-amber-700 text-[10px] inline-flex items-center">
|
||||
{STATUS_LABEL[row.status] ?? row.status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{row.blocker && <p className="mt-0.5 text-[11px] text-slate-500 leading-snug">{row.blocker}</p>}
|
||||
</div>
|
||||
<span className="hidden sm:inline text-[11px] text-slate-400 shrink-0">{row.seed ? "Seed" : ""}</span>
|
||||
{set.isPending && <Loader2Icon className="w-3.5 h-3.5 animate-spin text-slate-400 shrink-0" />}
|
||||
<span title={!canManage ? "You need the Manage mailboxes permission" : blocked ? row.blocker : undefined}>
|
||||
<Toggle
|
||||
value={row.seed}
|
||||
onChange={toggle}
|
||||
disabled={disabled}
|
||||
ariaLabel={row.seed ? `Stop using ${row.email} as a seed` : `Use ${row.email} as a seed`}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Shared vocabulary for inbox placement tests: folder labels and tones, rate
|
||||
// formatting, and the messages for every refusal the start endpoint returns.
|
||||
import type { AppError } from "@/lib/api/client/normalizeError";
|
||||
import buildError from "@/lib/helper/buildError";
|
||||
import type { DitherTone } from "@/components/ui/dither";
|
||||
import type {
|
||||
PlacementCounts,
|
||||
PlacementFolder,
|
||||
PlacementOverview,
|
||||
PlacementPanel,
|
||||
PlacementTest,
|
||||
PlacementTestStatus,
|
||||
} from "@/lib/api/models/app/placement/Placement";
|
||||
|
||||
export interface FolderStyle {
|
||||
label: string;
|
||||
tone: DitherTone;
|
||||
dot: string;
|
||||
text: string;
|
||||
chip: string;
|
||||
}
|
||||
|
||||
export const FOLDER: Record<PlacementFolder, FolderStyle> = {
|
||||
inbox: { label: "Inbox", tone: "emerald", dot: "bg-emerald-500", text: "text-emerald-600", chip: "bg-emerald-50 text-emerald-700 border-emerald-200" },
|
||||
promotions: { label: "Promotions", tone: "violet", dot: "bg-violet-500", text: "text-violet-600", chip: "bg-violet-50 text-violet-700 border-violet-200" },
|
||||
other: { label: "Other tab", tone: "sky", dot: "bg-sky-500", text: "text-sky-600", chip: "bg-sky-50 text-sky-700 border-sky-200" },
|
||||
spam: { label: "Spam", tone: "rose", dot: "bg-rose-500", text: "text-rose-600", chip: "bg-rose-50 text-rose-700 border-rose-200" },
|
||||
missing: { label: "Never arrived", tone: "slate", dot: "bg-slate-500", text: "text-slate-600", chip: "bg-slate-100 text-slate-700 border-slate-200" },
|
||||
pending: { label: "Waiting", tone: "slate", dot: "bg-slate-300", text: "text-slate-400", chip: "bg-white text-slate-500 border-slate-200" },
|
||||
failed: { label: "Not sent", tone: "amber", dot: "bg-amber-500", text: "text-amber-600", chip: "bg-amber-50 text-amber-700 border-amber-200" },
|
||||
cancelled: { label: "Cancelled", tone: "slate", dot: "bg-slate-300", text: "text-slate-400", chip: "bg-slate-50 text-slate-500 border-slate-200" },
|
||||
};
|
||||
|
||||
// The folders a copy that left can land in, in the order the bars stack.
|
||||
export const LANDED_FOLDERS = ["inbox", "promotions", "other", "spam", "missing"] as const;
|
||||
|
||||
export const STATUS: Record<PlacementTestStatus, { label: string; chip: string; dot: string }> = {
|
||||
running: { label: "Running", chip: "bg-sky-50 text-sky-700 border-sky-200", dot: "bg-sky-500 animate-pulse" },
|
||||
completed: { label: "Completed", chip: "bg-emerald-50 text-emerald-700 border-emerald-200", dot: "bg-emerald-500" },
|
||||
cancelled: { label: "Cancelled", chip: "bg-slate-50 text-slate-600 border-slate-200", dot: "bg-slate-400" },
|
||||
failed: { label: "Failed", chip: "bg-rose-50 text-rose-700 border-rose-200", dot: "bg-rose-500" },
|
||||
};
|
||||
|
||||
export const ORIGIN_LABEL: Record<string, string> = {
|
||||
manual: "Manual",
|
||||
monitor: "Monitor",
|
||||
admin: "Operator",
|
||||
remote: "Linked instance",
|
||||
};
|
||||
|
||||
/** A 0..1 fraction as a whole percentage, or a dash while there is none. */
|
||||
export function fmtRate(r: number | null | undefined): string {
|
||||
if (r == null) return "—";
|
||||
return `${Math.round(r * 100)}%`;
|
||||
}
|
||||
|
||||
export function rateTone(r: number | null | undefined): string {
|
||||
if (r == null) return "text-slate-400";
|
||||
if (r >= 0.8) return "text-emerald-600";
|
||||
if (r >= 0.6) return "text-amber-600";
|
||||
return "text-rose-600";
|
||||
}
|
||||
|
||||
/** Copies that have a verdict, sent or not. */
|
||||
export function resolvedCount(c: PlacementCounts): number {
|
||||
return Math.max(0, c.total - c.pending);
|
||||
}
|
||||
|
||||
export function isTracked(t: Pick<PlacementTest, "open_tracking" | "link_tracking">): boolean {
|
||||
return t.open_tracking || t.link_tracking;
|
||||
}
|
||||
|
||||
export function fmtDate(d: Date | string | null | undefined): string {
|
||||
if (!d) return "—";
|
||||
const date = d instanceof Date ? d : new Date(d);
|
||||
if (Number.isNaN(date.getTime())) return "—";
|
||||
return date.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
|
||||
}
|
||||
|
||||
export function fmtDay(d: Date | string | null | undefined): string {
|
||||
if (!d) return "—";
|
||||
const date = d instanceof Date ? d : new Date(d);
|
||||
if (Number.isNaN(date.getTime())) return "—";
|
||||
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
export function usageLabel(o: PlacementOverview | undefined): string {
|
||||
if (!o) return "";
|
||||
const { used, limit } = o.usage;
|
||||
if (limit == null) return "Unlimited";
|
||||
return `${used} of ${limit} test${limit === 1 ? "" : "s"} this month`;
|
||||
}
|
||||
|
||||
// Which part of the new-test form a refusal is about, so it shows beside it.
|
||||
export type PlacementErrorField = "sender" | "source" | "tracking" | "panel" | "general";
|
||||
|
||||
export function placementErrorMessage(
|
||||
err: AppError,
|
||||
ctx: { resetsOn?: Date | null; panel?: PlacementPanel } = {},
|
||||
): { field: PlacementErrorField; message: string } {
|
||||
switch (err?.code) {
|
||||
case "placement_not_entitled":
|
||||
return { field: "general", message: "Placement tests need an active trial or subscription." };
|
||||
case "placement_quota_exceeded":
|
||||
return {
|
||||
field: "panel",
|
||||
message: `This month's placement tests are used up${ctx.resetsOn ? ` until ${fmtDay(ctx.resetsOn)}` : ""}. Tests on your own seed inboxes are never counted.`,
|
||||
};
|
||||
case "placement_too_many_running":
|
||||
return { field: "general", message: "Three tests are already running in this workspace. Wait for one to finish, or cancel one." };
|
||||
case "placement_sender_busy":
|
||||
return { field: "sender", message: "This mailbox is still sending another test. Pick another mailbox or wait until that one has sent every copy." };
|
||||
case "placement_sender_unavailable":
|
||||
return { field: "sender", message: "This mailbox cannot send a test: it is not connected, or it is a seed inbox itself." };
|
||||
case "placement_daily_budget":
|
||||
return { field: "sender", message: "This mailbox does not have enough of today's sending limit left for every copy. Pick another mailbox or try again tomorrow." };
|
||||
case "placement_no_seeds":
|
||||
return {
|
||||
field: "panel",
|
||||
message:
|
||||
ctx.panel === "workspace"
|
||||
? "None of your seed inboxes can take this test. Add a seed inbox on a different domain than the sender."
|
||||
: "This panel has no seed inbox this mailbox can reach. Seeds on the sender's own domain are always skipped.",
|
||||
};
|
||||
case "placement_panel_unavailable":
|
||||
return { field: "panel", message: "The Warmbly Cloud panel needs this instance linked to Warmbly Cloud." };
|
||||
case "placement_invalid_tracking":
|
||||
return { field: "tracking", message: "This campaign sends plain text, which carries no tracking. Pick Off or As the campaign." };
|
||||
default:
|
||||
return { field: "general", message: buildError(err) };
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ListChecksIcon,
|
||||
type LucideIcon,
|
||||
MailIcon,
|
||||
MailCheckIcon,
|
||||
MegaphoneIcon,
|
||||
SettingsIcon,
|
||||
ShieldCheckIcon,
|
||||
@@ -151,6 +152,7 @@ const sections: NavSection[] = [
|
||||
{ title: "Forms", requires: "subscription", url: "/app/forms", icon: ClipboardListIcon, permission: "VIEW_CONTACTS", permissionLabel: "View contacts" },
|
||||
{ title: "Analytics", requires: "subscription", url: "/app/analytics", icon: BarChart3Icon, indicator: "analytics", permission: "VIEW_ANALYTICS", permissionLabel: "View analytics" },
|
||||
{ title: "Deliverability", requires: "subscription", url: "/app/deliverability", icon: ShieldCheckIcon, advisorSurface: "deliverability", permission: "VIEW_ANALYTICS", permissionLabel: "View analytics" },
|
||||
{ title: "Placement tests", requires: "subscription", url: "/app/placement", icon: MailCheckIcon, permission: "VIEW_ANALYTICS", permissionLabel: "View analytics" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
ClockIcon,
|
||||
CreditCardIcon,
|
||||
KeyRoundIcon,
|
||||
MailCheckIcon,
|
||||
MailWarningIcon,
|
||||
ReplyIcon,
|
||||
ServerCrashIcon,
|
||||
PauseCircleIcon,
|
||||
@@ -40,6 +42,8 @@ const CATEGORY_META: Record<string, { icon: LucideIcon; tone: string }> = {
|
||||
team_activity: { icon: UsersIcon, tone: "bg-emerald-50 text-emerald-600" },
|
||||
campaign_paused: { icon: PauseCircleIcon, tone: "bg-rose-50 text-rose-600" },
|
||||
health_domain_auth: { icon: ShieldOffIcon, tone: "bg-rose-50 text-rose-600" },
|
||||
placement_finished: { icon: MailCheckIcon, tone: "bg-sky-50 text-sky-600" },
|
||||
placement_alert: { icon: MailWarningIcon, tone: "bg-rose-50 text-rose-600" },
|
||||
};
|
||||
|
||||
const FALLBACK_META = { icon: BellIcon, tone: "bg-slate-100 text-slate-500" };
|
||||
|
||||
@@ -50,6 +50,7 @@ const ROUTE_TITLES: Record<string, string> = {
|
||||
"/app/campaigns": "Campaigns",
|
||||
"/app/analytics": "Analytics",
|
||||
"/app/deliverability": "Deliverability",
|
||||
"/app/placement": "Placement tests",
|
||||
"/app/crm/pipelines": "Pipelines",
|
||||
"/app/crm/deals": "Deals",
|
||||
"/app/crm/tasks": "Tasks",
|
||||
@@ -98,6 +99,7 @@ const PARAM_ROUTES: ReadonlyArray<readonly [RegExp, string]> = [
|
||||
[/^\/app\/campaigns\/[^/]+\/steps$/, "Campaign steps"],
|
||||
[/^\/app\/campaigns\/[^/]+$/, "Campaign"],
|
||||
[/^\/app\/automations\/[^/]+$/, "Automation"],
|
||||
[/^\/app\/placement\/[^/]+$/, "Placement test"],
|
||||
[/^\/app\/forms\/[^/]+$/, "Form"],
|
||||
[/^\/app\/contacts\/segments\/[^/]+$/, "Segment"],
|
||||
[/^\/app\/unibox(\/.*)?$/, "Unibox"],
|
||||
|
||||
@@ -73,6 +73,14 @@ export function useRealtimeEvents() {
|
||||
// and must never trigger a react-query refetch.
|
||||
if (event.startsWith('LIVE_')) return
|
||||
|
||||
// A placement test started, got a verdict, finished or was cancelled.
|
||||
// Checked this early because its name would otherwise match the warmup
|
||||
// placement and campaign branches below.
|
||||
if (event === 'PLACEMENT_TEST_UPDATED') {
|
||||
invalidate([['placement'], ['analytics', 'deliverability']])
|
||||
return
|
||||
}
|
||||
|
||||
const getString = (key: string) => {
|
||||
const value = payload[key]
|
||||
return typeof value === 'string' && value.length > 0 ? value : null
|
||||
@@ -421,7 +429,8 @@ export function useRealtimeEvents() {
|
||||
// rolled plan), so a teammate retuning a mailbox's sending behaviour
|
||||
// refreshes everyone's open drawer instead of only the list row.
|
||||
// A mailbox write can move it off Google sign-in, so the migration list follows.
|
||||
email_account: [['emails'], ['analytics', 'accounts'], ['sending-domains'], ['mailbox-grants', 'migration'], ['pool-link']],
|
||||
// A seed toggle is audited here too, so the placement seed list follows.
|
||||
email_account: [['emails'], ['analytics', 'accounts'], ['sending-domains'], ['mailbox-grants', 'migration'], ['pool-link'], ['placement', 'seeds'], ['placement', 'overview']],
|
||||
// A mailbox import created, retried or cancelled by a teammate; it may move mailboxes onto a grant.
|
||||
mailbox_import: [['emails', 'imports'], ['mailbox-grants', 'migration']],
|
||||
// An admin grant added, re-checked or removed, and an inbox vendor
|
||||
@@ -481,6 +490,9 @@ export function useRealtimeEvents() {
|
||||
crm_deal: [['crm', 'deals'], ['contacts']],
|
||||
crm_task: [['crm', 'tasks'], ['crm', 'deals']],
|
||||
warmup_routing_rule: [['analytics', 'warmup']],
|
||||
// Placement tests started or stopped and campaign monitors changed.
|
||||
placement_test: [['placement']],
|
||||
placement_monitor: [['placement']],
|
||||
cloud_link: [['cloud-link'], ['emails']],
|
||||
pool_link: [['pool-link'], ['emails']],
|
||||
// Folders / tags / categories ride the user payload.
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import type {
|
||||
CreatePlacementTestRequest,
|
||||
PlacementMonitor,
|
||||
PlacementMonitorInput,
|
||||
PlacementOverview,
|
||||
PlacementTest,
|
||||
PlacementTestDetail,
|
||||
PlacementTestList,
|
||||
PlacementWorkspaceSeed,
|
||||
} from "@/lib/api/models/app/placement/Placement";
|
||||
import Request from "../../Request";
|
||||
|
||||
export async function getPlacementOverview(): Promise<PlacementOverview> {
|
||||
const res = await Request<{ data: PlacementOverview }>({
|
||||
method: "GET",
|
||||
url: "/placement/overview",
|
||||
authorization: true,
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function listPlacementTests(
|
||||
cursor: string | null,
|
||||
limit: number,
|
||||
campaignId?: string | null,
|
||||
): Promise<PlacementTestList> {
|
||||
const params = new URLSearchParams();
|
||||
params.set("limit", String(limit));
|
||||
if (cursor) params.set("cursor", cursor);
|
||||
if (campaignId) params.set("campaign_id", campaignId);
|
||||
return await Request<PlacementTestList>({
|
||||
method: "GET",
|
||||
url: `/placement/tests?${params.toString()}`,
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPlacementTest(id: string): Promise<PlacementTestDetail> {
|
||||
const res = await Request<{ data: PlacementTestDetail }>({
|
||||
method: "GET",
|
||||
url: `/placement/tests/${id}`,
|
||||
authorization: true,
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
// One test, or two sharing a compare_group_id for a tracking comparison. The
|
||||
// key makes a retried submit land on the tests the first attempt started.
|
||||
export async function createPlacementTest(
|
||||
body: CreatePlacementTestRequest,
|
||||
idempotencyKey?: string,
|
||||
): Promise<PlacementTest[]> {
|
||||
const res = await Request<{ data: PlacementTest[] | null }>({
|
||||
method: "POST",
|
||||
url: "/placement/tests",
|
||||
data: body,
|
||||
headers: idempotencyKey ? { "Idempotency-Key": idempotencyKey } : undefined,
|
||||
authorization: true,
|
||||
});
|
||||
return res.data ?? [];
|
||||
}
|
||||
|
||||
// Stops the copies not sent yet; the sent ones keep being classified.
|
||||
export async function cancelPlacementTest(id: string): Promise<PlacementTest> {
|
||||
const res = await Request<{ data: PlacementTest }>({
|
||||
method: "POST",
|
||||
url: `/placement/tests/${id}/cancel`,
|
||||
authorization: true,
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function listPlacementSeeds(): Promise<PlacementWorkspaceSeed[]> {
|
||||
const res = await Request<{ data: PlacementWorkspaceSeed[] | null }>({
|
||||
method: "GET",
|
||||
url: "/placement/seeds",
|
||||
authorization: true,
|
||||
});
|
||||
return res.data ?? [];
|
||||
}
|
||||
|
||||
export async function setPlacementSeed(emailAccountId: string, seed: boolean): Promise<PlacementWorkspaceSeed> {
|
||||
const res = await Request<{ data: PlacementWorkspaceSeed }>({
|
||||
method: "PUT",
|
||||
url: `/placement/seeds/${emailAccountId}`,
|
||||
data: { seed },
|
||||
authorization: true,
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getPlacementMonitor(campaignId: string): Promise<PlacementMonitor | null> {
|
||||
const res = await Request<{ data: PlacementMonitor | null }>({
|
||||
method: "GET",
|
||||
url: `/campaigns/${campaignId}/placement-monitor`,
|
||||
authorization: true,
|
||||
});
|
||||
return res.data ?? null;
|
||||
}
|
||||
|
||||
// A full-state write, so a retry lands on the same monitor.
|
||||
export async function putPlacementMonitor(campaignId: string, input: PlacementMonitorInput): Promise<PlacementMonitor> {
|
||||
const res = await Request<{ data: PlacementMonitor }>({
|
||||
method: "PUT",
|
||||
url: `/campaigns/${campaignId}/placement-monitor`,
|
||||
data: input,
|
||||
authorization: true,
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deletePlacementMonitor(campaignId: string): Promise<void> {
|
||||
await Request<void>({
|
||||
method: "DELETE",
|
||||
url: `/campaigns/${campaignId}/placement-monitor`,
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useContext } from "react";
|
||||
import { SocketContext } from "@/hooks/context/socket";
|
||||
import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient, type InfiniteData } from "@tanstack/react-query";
|
||||
import {
|
||||
cancelPlacementTest,
|
||||
createPlacementTest,
|
||||
deletePlacementMonitor,
|
||||
getPlacementMonitor,
|
||||
getPlacementOverview,
|
||||
getPlacementTest,
|
||||
listPlacementSeeds,
|
||||
listPlacementTests,
|
||||
putPlacementMonitor,
|
||||
setPlacementSeed,
|
||||
} from "@/lib/api/client/app/placement/placement";
|
||||
import type {
|
||||
CreatePlacementTestRequest,
|
||||
PlacementMonitorInput,
|
||||
PlacementTestList,
|
||||
} from "@/lib/api/models/app/placement/Placement";
|
||||
|
||||
// Everything lives under ["placement"], which PLACEMENT_TEST_UPDATED and the
|
||||
// placement_test / placement_monitor audit spine invalidate, so these views
|
||||
// stay live with no polling.
|
||||
export const PLACEMENT_KEY = ["placement"] as const;
|
||||
|
||||
export function usePlacementOverview(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: [...PLACEMENT_KEY, "overview"],
|
||||
queryFn: getPlacementOverview,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePlacementTests(campaignId: string | null = null, limit = 25) {
|
||||
const query = useInfiniteQuery<
|
||||
PlacementTestList,
|
||||
Error,
|
||||
InfiniteData<PlacementTestList, string | null>,
|
||||
(string | number | null)[],
|
||||
string | null
|
||||
>({
|
||||
queryKey: [...PLACEMENT_KEY, "tests", campaignId, limit],
|
||||
queryFn: ({ pageParam }) => listPlacementTests(pageParam, limit, campaignId),
|
||||
initialPageParam: null,
|
||||
getNextPageParam: (last) => (last.pagination.has_more ? last.pagination.next_cursor : undefined),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
const tests = query.data?.pages.flatMap((p) => p.data ?? []) ?? [];
|
||||
const total = query.data?.pages[0]?.pagination.total ?? null;
|
||||
return { ...query, tests, total };
|
||||
}
|
||||
|
||||
export function usePlacementTest(id: string) {
|
||||
// Realtime drives a running test; only a dropped socket falls back to a slow poll.
|
||||
const socketUp = useContext(SocketContext)?.isConnected ?? true;
|
||||
return useQuery({
|
||||
queryKey: [...PLACEMENT_KEY, "test", id],
|
||||
queryFn: () => getPlacementTest(id),
|
||||
enabled: !!id,
|
||||
refetchInterval: (query) => (!socketUp && query.state.data?.status === "running" ? 15_000 : false),
|
||||
});
|
||||
}
|
||||
|
||||
export function usePlacementSeeds(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: [...PLACEMENT_KEY, "seeds"],
|
||||
queryFn: listPlacementSeeds,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreatePlacementTest() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ body, idempotencyKey }: { body: CreatePlacementTestRequest; idempotencyKey?: string }) =>
|
||||
createPlacementTest(body, idempotencyKey),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: PLACEMENT_KEY });
|
||||
// Every copy is charged to the sender's daily limit.
|
||||
qc.invalidateQueries({ queryKey: ["emails", "list"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCancelPlacementTest() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => cancelPlacementTest(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: PLACEMENT_KEY }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetPlacementSeed() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ emailAccountId, seed }: { emailAccountId: string; seed: boolean }) =>
|
||||
setPlacementSeed(emailAccountId, seed),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: PLACEMENT_KEY });
|
||||
// Marking a seed turns its warmup off.
|
||||
qc.invalidateQueries({ queryKey: ["emails"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function usePlacementMonitor(campaignId: string) {
|
||||
return useQuery({
|
||||
queryKey: [...PLACEMENT_KEY, "monitor", campaignId],
|
||||
queryFn: () => getPlacementMonitor(campaignId),
|
||||
enabled: !!campaignId,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePutPlacementMonitor(campaignId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: PlacementMonitorInput) => putPlacementMonitor(campaignId, input),
|
||||
onSuccess: (m) => {
|
||||
qc.setQueryData([...PLACEMENT_KEY, "monitor", campaignId], m);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeletePlacementMonitor(campaignId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => deletePlacementMonitor(campaignId),
|
||||
onSuccess: () => {
|
||||
qc.setQueryData([...PLACEMENT_KEY, "monitor", campaignId], null);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -36,13 +36,18 @@ export interface CampaignDeliverability {
|
||||
band: DeliverabilityBand;
|
||||
}
|
||||
|
||||
// One mail host family's seed placement: `provider` is the family id
|
||||
// (gmail, microsoft365, yahoo, ...) and `label` its display name.
|
||||
export interface ProviderPlacement {
|
||||
provider: string;
|
||||
label?: string;
|
||||
samples: number;
|
||||
inbox: number;
|
||||
promotions: number;
|
||||
spam: number;
|
||||
other: number;
|
||||
// Copies that never arrived within the detection window.
|
||||
missing?: number;
|
||||
inbox_rate: number;
|
||||
spam_rate: number;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ export interface NotificationPreferences {
|
||||
team_activity: CategoryPref;
|
||||
campaign_paused: CategoryPref;
|
||||
health_domain_auth: CategoryPref;
|
||||
placement_finished: CategoryPref;
|
||||
placement_alert: CategoryPref;
|
||||
email_digest_minutes: number;
|
||||
}
|
||||
|
||||
@@ -74,6 +76,10 @@ export function normalizeNotificationPreferences(
|
||||
// Emails by default too: a sending domain the platform will stop
|
||||
// sending from has to reach whoever can edit the DNS.
|
||||
health_domain_auth: p?.health_domain_auth ?? billing,
|
||||
placement_finished: p?.placement_finished ?? on,
|
||||
// Emails by default: a campaign landing in spam has to reach whoever
|
||||
// can fix it even when nobody has the dashboard open.
|
||||
placement_alert: p?.placement_alert ?? billing,
|
||||
email_digest_minutes: Math.min(Math.max(minutes, EMAIL_WINDOW_MIN_MINUTES), EMAIL_WINDOW_MAX_MINUTES),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
// Inbox placement tests (mirrors internal/models/placement.go and
|
||||
// internal/app/placement/view.go). Timestamps arrive as ISO strings and are
|
||||
// revived into Date objects by Request.
|
||||
import type Pagination from "../Pagination";
|
||||
import type { TemplateScoreIssue } from "../campaigns/TemplateScore";
|
||||
|
||||
export type PlacementPanel = "instance" | "workspace" | "cloud";
|
||||
export type PlacementTracking = "campaign" | "on" | "off" | "compare";
|
||||
export type PlacementTestStatus = "running" | "completed" | "cancelled" | "failed";
|
||||
export type PlacementOrigin = "manual" | "monitor" | "admin" | "remote";
|
||||
export type PlacementFolder = "pending" | "inbox" | "promotions" | "other" | "spam" | "missing" | "failed" | "cancelled";
|
||||
|
||||
export interface PlacementPanelFamily {
|
||||
family: string;
|
||||
label: string;
|
||||
seeds: number;
|
||||
}
|
||||
|
||||
export interface PlacementPanelInfo {
|
||||
panel: PlacementPanel;
|
||||
available: boolean;
|
||||
/** One sentence saying why, when the panel is unavailable. */
|
||||
reason?: string;
|
||||
seeds: number;
|
||||
families: PlacementPanelFamily[] | null;
|
||||
/** Counts against the monthly allowance. */
|
||||
metered: boolean;
|
||||
}
|
||||
|
||||
export interface PlacementUsage {
|
||||
used: number;
|
||||
/** null = unmetered. */
|
||||
limit: number | null;
|
||||
period_start: Date;
|
||||
period_end: Date;
|
||||
}
|
||||
|
||||
export interface PlacementOverview {
|
||||
panels: PlacementPanelInfo[];
|
||||
usage: PlacementUsage;
|
||||
workspace_seeds: number;
|
||||
/** Most seeds one test sends to. */
|
||||
seeds_per_test: number;
|
||||
spacing_seconds: number;
|
||||
}
|
||||
|
||||
// Rates are fractions 0..1 of `delivered`, null while nothing has a verdict.
|
||||
export interface PlacementCounts {
|
||||
total: number;
|
||||
pending: number;
|
||||
inbox: number;
|
||||
promotions: number;
|
||||
other: number;
|
||||
spam: number;
|
||||
missing: number;
|
||||
failed: number;
|
||||
cancelled: number;
|
||||
/** inbox + promotions + other + spam + missing. */
|
||||
delivered: number;
|
||||
inbox_rate: number | null;
|
||||
tabs_rate: number | null;
|
||||
spam_rate: number | null;
|
||||
missing_rate: number | null;
|
||||
}
|
||||
|
||||
export interface PlacementFamilyCounts {
|
||||
family: string;
|
||||
label: string;
|
||||
counts: PlacementCounts;
|
||||
}
|
||||
|
||||
export interface PlacementTest {
|
||||
id: string;
|
||||
sender_account_id: string | null;
|
||||
sender_email: string;
|
||||
created_by: string | null;
|
||||
campaign_id: string | null;
|
||||
sequence_id: string | null;
|
||||
contact_id: string | null;
|
||||
monitor_id: string | null;
|
||||
subject: string;
|
||||
/** Only on GET /placement/tests/:id. */
|
||||
body_html?: string;
|
||||
body_plain?: string;
|
||||
open_tracking: boolean;
|
||||
link_tracking: boolean;
|
||||
compare_group_id: string | null;
|
||||
origin: PlacementOrigin;
|
||||
panel: PlacementPanel;
|
||||
status: PlacementTestStatus;
|
||||
error?: string;
|
||||
created_at: Date;
|
||||
finished_at: Date | null;
|
||||
summary: PlacementCounts;
|
||||
families: PlacementFamilyCounts[] | null;
|
||||
}
|
||||
|
||||
export interface PlacementResult {
|
||||
/** Masked ("a***@gmail.com") on the instance and cloud panels. */
|
||||
seed: string;
|
||||
family: string;
|
||||
family_label: string;
|
||||
folder: PlacementFolder;
|
||||
scheduled_at: Date | null;
|
||||
sent_at: Date | null;
|
||||
detected_at: Date | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PlacementContentCheck {
|
||||
score: number;
|
||||
issues: TemplateScoreIssue[] | null;
|
||||
}
|
||||
|
||||
export interface PlacementTestDetail extends PlacementTest {
|
||||
results: PlacementResult[] | null;
|
||||
content: PlacementContentCheck;
|
||||
/** The other half of a tracking comparison. */
|
||||
compare?: PlacementTest;
|
||||
}
|
||||
|
||||
export interface PlacementTestList {
|
||||
data: PlacementTest[] | null;
|
||||
pagination: Pagination;
|
||||
}
|
||||
|
||||
export interface CreatePlacementTestRequest {
|
||||
sender_account_id: string;
|
||||
campaign_id?: string;
|
||||
sequence_id?: string;
|
||||
contact_id?: string;
|
||||
subject?: string;
|
||||
body_html?: string;
|
||||
body_plain?: string;
|
||||
tracking?: PlacementTracking;
|
||||
panel?: PlacementPanel;
|
||||
}
|
||||
|
||||
export interface PlacementWorkspaceSeed {
|
||||
email_account_id: string;
|
||||
email: string;
|
||||
family: string;
|
||||
label: string;
|
||||
status: "active" | "inactive" | "revoked" | string;
|
||||
seed: boolean;
|
||||
/** Why the mailbox cannot be toggled right now. */
|
||||
blocker?: string;
|
||||
}
|
||||
|
||||
export interface PlacementMonitor {
|
||||
id: string;
|
||||
campaign_id: string;
|
||||
created_by: string | null;
|
||||
enabled: boolean;
|
||||
interval_days: number;
|
||||
panel: PlacementPanel;
|
||||
/** Primary-inbox percentage (0-100) below which the monitor alerts. */
|
||||
alert_below: number;
|
||||
pause_on_alert: boolean;
|
||||
next_run_at: Date;
|
||||
last_run_at: Date | null;
|
||||
last_test_id: string | null;
|
||||
last_alert_at: Date | null;
|
||||
last_error?: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface PlacementMonitorInput {
|
||||
enabled?: boolean;
|
||||
interval_days?: number;
|
||||
panel?: PlacementPanel;
|
||||
alert_below?: number;
|
||||
pause_on_alert?: boolean;
|
||||
}
|
||||
|
||||
export const PLACEMENT_MONITOR_INTERVAL_MIN = 1;
|
||||
export const PLACEMENT_MONITOR_INTERVAL_MAX = 30;
|
||||
|
||||
export const PANEL_LABEL: Record<PlacementPanel, string> = {
|
||||
instance: "Shared panel",
|
||||
workspace: "Your seed inboxes",
|
||||
cloud: "Warmbly Cloud panel",
|
||||
};
|
||||
|
||||
export const PANEL_HINT: Record<PlacementPanel, string> = {
|
||||
instance: "Seed inboxes run for every workspace on this instance.",
|
||||
workspace: "Test mailboxes your workspace marked as seeds.",
|
||||
cloud: "Warmbly Cloud's seed inboxes, reached through your linked account.",
|
||||
};
|
||||
@@ -29,6 +29,8 @@ import CampaignSchedule from './app/app/campaigns/[id]/schedule/page';
|
||||
import CampaignSteps from './app/app/campaigns/[id]/steps/page';
|
||||
import AnalyticsPage from './app/app/analytics/page';
|
||||
import DeliverabilityPage from './app/app/deliverability/page';
|
||||
import PlacementPage from './app/app/placement/page';
|
||||
import PlacementTestPage from './app/app/placement/[id]/page';
|
||||
import PipelinesPage from './app/app/crm/pipelines/page';
|
||||
import DealsPage from './app/app/crm/deals/page';
|
||||
import TasksPage from './app/app/crm/tasks/page';
|
||||
@@ -318,6 +320,13 @@ const router = createBrowserRouter([
|
||||
path: "deliverability",
|
||||
element: <DeliverabilityPage />,
|
||||
},
|
||||
{
|
||||
path: "placement",
|
||||
children: [
|
||||
{ index: true, element: <PlacementPage /> },
|
||||
{ path: ":id", element: <PlacementTestPage /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "crm",
|
||||
children: [
|
||||
|
||||
Reference in New Issue
Block a user