From eed7fd31c8d364d013ebd104dd4327453df9953b Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Tue, 9 Jun 2026 06:18:04 +0200 Subject: [PATCH] feat: redesign campaigns page Refresh the campaigns landing page around the visual sequence builder, branching logic, A/B variants, sender pool distribution, and updated safety defaults. Add the icon paths needed by the new page sections. --- site/src/components/Icon.astro | 4 + site/src/pages/campaigns.astro | 789 ++++++++++++++++++++++++--------- 2 files changed, 583 insertions(+), 210 deletions(-) diff --git a/site/src/components/Icon.astro b/site/src/components/Icon.astro index 992d58b18..6ac95ff53 100644 --- a/site/src/components/Icon.astro +++ b/site/src/components/Icon.astro @@ -44,6 +44,10 @@ const icons: Record = { webhook: '', brain: '', clock: '', + gitBranch: '', + flag: '', + flask: '', + shuffle: '', link: '', warning: '', star: '', diff --git a/site/src/pages/campaigns.astro b/site/src/pages/campaigns.astro index b207063e6..d05fd4379 100644 --- a/site/src/pages/campaigns.astro +++ b/site/src/pages/campaigns.astro @@ -4,82 +4,186 @@ import HeroAtmosphere from '../components/HeroAtmosphere.astro'; import Icon from '../components/Icon.astro'; import CTA from '../components/CTA.astro'; -// The numbers below come from the Warmbly codebase / CLAUDE.md -// (internal/scheduler/campaign_scheduler.go, internal/models/email.go). +// =========================================================================== +// Campaigns page. The centrepiece is a faithful, static recreation of the +// in-product sequence builder (web/src/components/app/campaigns/sequences): +// email-step cards with a sky header + "On reply" footer, IF/branch nodes with +// an "instant" badge, action nodes, stop nodes, and wait pills on the +// connectors. Numbers are real defaults from the codebase: +// internal/config/constants.go (50/day cap, 600s gap) +// internal/scheduler/campaign_scheduler.go +// web sequence builder (wait_after 0..60 days, weight 1..100) +// +// Astro JSX caveat: never put a literal `<`, `>` or `{{ }}` inside a {} +// expression or in markup. Template tokens are written as {`{{.Field}}`}. +// =========================================================================== -const limits = [ - { k: 'Steps per sequence', v: '12', why: '1 initial outreach + 11 follow-ups. Beyond that, reply rate drops below the harm threshold.' }, - { k: 'Variants per step', v: '4', why: 'Up to four subject and body variants. We track which variant earned each reply.' }, - { k: 'Minimum step delay', v: '1 business day', why: 'Same-day follow-ups read as bot behaviour to receivers. We enforce ≥1 day.' }, - { k: 'Per-mailbox cap', v: '100 / day', why: 'Default cold send budget per mailbox. Hard cap before content scoring kicks in.' }, - { k: 'Send spacing', v: '~10 min', why: 'Minimum gap between sends from the same mailbox. Matches human typing cadence.' }, - { k: 'Business hours', v: 'Recipient TZ', why: 'Sends scheduled in the recipient\'s working window, not the sender\'s.' }, +// --- Step types legend ------------------------------------------------------- +const stepTypes = [ + { + icon: 'mail', name: 'Email step', tag: 'sends copy', + badge: 'bg-sky-100 text-sky-600 ring-sky-200/70', + desc: 'Sends one templated email. Holds the subject, the body, any A/B variants, and the wait before whatever comes next.', + }, + { + icon: 'gitBranch', name: 'Branch', tag: 'routes contacts', + badge: 'bg-sky-100 text-sky-600 ring-sky-200/70', + desc: 'Splits the path on a signal: opened, clicked, replied, or a share at random. Then and else can each point anywhere.', + }, + { + icon: 'shuffle', name: 'Action', tag: 'side effect', + badge: 'bg-violet-100 text-violet-600 ring-violet-200/70', + desc: 'No email. Add a tag, create a task or deal, move a CRM stage, notify a channel, or unsubscribe the contact.', + }, + { + icon: 'flag', name: 'Stop', tag: 'ends here', + badge: 'bg-rose-100 text-rose-600 ring-rose-200/70', + desc: 'Closes the sequence for that contact. Reached on a break-up step, a met goal, or an else with nowhere left to go.', + }, ]; -// Demo sequence used in the hero mock + flow diagram -const sequence = [ - { idx: 1, kind: 'send', delay: 'D0', label: 'Initial outreach', variants: 3, note: 'Plain text · 4 sentences' }, - { idx: 2, kind: 'wait', delay: '+3d', label: 'Wait', variants: 0, note: 'Business days, recipient TZ' }, - { idx: 3, kind: 'send', delay: 'D3', label: 'Soft follow-up', variants: 2, note: 'Reply prompts top quoted' }, - { idx: 4, kind: 'branch', delay: '+2d', label: 'Branch · opened ≥ 2?', variants: 0, note: 'Take fast lane if engaged' }, - { idx: 5, kind: 'send', delay: 'D5', label: 'Referral ask', variants: 1, note: 'Asks for the right person' }, - { idx: 6, kind: 'send', delay: 'D12', label: 'Final break-up', variants: 2, note: 'Last touch · permission close' }, - { idx: 7, kind: 'event', delay: 'on reply', label: 'Auto-pause', variants: 0, note: 'Sequence halts for this contact' }, +// --- Branch routing signals -------------------------------------------------- +const routing = [ + { sig: 'replied', instant: true, desc: 'The moment a reply lands. Usually pulls the contact off the rest of the sequence.' }, + { sig: 'opened', instant: true, desc: 'As the open returns, or after a set number of days if you would rather wait and decide.' }, + { sig: 'clicked', instant: true, desc: 'A click on any tracked link, instantly or inside a window you choose.' }, + { sig: 'not opened', instant: false, desc: 'No open inside the window. The classic break-up trigger.' }, + { sig: 'random %', instant: false, desc: 'Send a share of contacts down a different path. A/B at the level of the whole flow.' }, + { sig: 'always', instant: false, desc: 'An unconditional else. Everyone who reaches it takes this path.' }, ]; -const kindStyle = (k: string) => { - if (k === 'send') return { dot: 'bg-[#0284c7]', chip: 'bg-[color:var(--sky-1)] text-[#0369a1]', label: 'SEND' }; - if (k === 'wait') return { dot: 'bg-slate-400', chip: 'bg-slate-100 text-slate-700', label: 'WAIT' }; - if (k === 'branch') return { dot: 'bg-amber-500', chip: 'bg-amber-50 text-amber-700', label: 'BRANCH' }; - return { dot: 'bg-emerald-500', chip: 'bg-emerald-50 text-emerald-700', label: 'EVENT' }; -}; +// --- A/B variants ------------------------------------------------------------ +const variants = [ + { name: 'Original', weight: 60, subject: 'Quick question, {{.FirstName}}', original: true }, + { name: 'Variant B', weight: 25, subject: '{{.Company}} + a 20-minute look', original: false }, + { name: 'Variant C', weight: 15, subject: 'worth closing the loop?', original: false }, +]; +// --- Sender pool ------------------------------------------------------------- const senderPool = [ - { addr: 'ben@acme.com', role: 'AE', cap: 100, today: 47 }, - { addr: 'sara@acme.com', role: 'AE', cap: 100, today: 51 }, - { addr: 'mark@acme.com', role: 'BDR', cap: 100, today: 49 }, - { addr: 'kai@acme.com', role: 'BDR', cap: 100, today: 53 }, + { addr: 'ben@acme.com', role: 'AE', cap: 50, today: 31 }, + { addr: 'sara@acme.com', role: 'AE', cap: 50, today: 28 }, + { addr: 'mark@acme.com', role: 'BDR', cap: 50, today: 34 }, + { addr: 'kai@acme.com', role: 'BDR', cap: 50, today: 26 }, ]; +// --- Auto-stop rules --------------------------------------------------------- const autoStop = [ - ['Replied', 'Recipient stopped, sequence pauses for them. Other recipients unaffected.'], - ['Hard bounce', 'Recipient suppressed across the workspace. No further sends.'], - ['Soft bounce x3', 'Recipient suppressed after the third soft bounce on the same domain.'], - ['Unsubscribed', 'One-click unsubscribe via list-unsubscribe header. Suppression applied immediately.'], - ['OOO classified', 'Sequence pauses until the OOO end date parsed from the body.'], - ['Manual suppress', 'Operator-added suppressions apply across all sequences in the workspace.'], + ['Replied', 'The reply pulls the contact off the sequence the instant it lands. Everyone else keeps going.'], + ['Goal met', 'A booked meeting or a won deal closes the sequence for that contact automatically.'], + ['Hard bounce', 'The recipient is suppressed across the whole workspace. Nothing else ever queues to them.'], + ['Unsubscribed', 'One-click unsubscribe, RFC 8058. Suppression applies before the confirmation page loads.'], + ['Reply: STOP', 'Opt-out phrases in a reply are caught and suppressed without anyone in the loop.'], + ['Manual suppress', 'An operator or an uploaded list suppresses the contact across every sequence at once.'], ]; -const faq = [ - ['Can a sequence have parallel paths?', 'Yes. A branch step evaluates a condition (opened, clicked, replied, custom field) and selects which following step to run next. Branches do not fan out in parallel; one path is chosen per recipient.'], - ['How do A/B variants get picked?', 'Variants are assigned per-recipient at sequence start using a stable hash so the same recipient always sees the same variant across retries. We report which variant earned the reply.'], - ['What if a sender mailbox goes over its cap mid-day?', 'The scheduler skips it for the rest of the day and round-robins the remaining sends across the other healthy mailboxes in the pool. The sequence does not stall.'], - ['Are sequences versioned?', 'Yes. Saving a sequence creates a new version. In-flight recipients continue on the version they started on; new recipients join the latest version.'], +// --- Limits / defaults ------------------------------------------------------- +const limits = [ + { k: 'Wait between steps', v: '0 to 60 days', why: 'Set per step. Same-day follow-ups read as automation, so most steps sit a few business days apart.' }, + { k: 'Branch signals', v: 'open · click · reply', why: 'Route on a real engagement signal, inside a window you set or the instant it happens.' }, + { k: 'A/B split', v: 'weight 1 to 100', why: 'Add variants to any email step. Volume splits by weight and we track which one earns the reply.' }, + { k: 'Per-mailbox cap', v: '50 / day', why: 'Default cold budget per mailbox. Raise it toward 100 once the mailbox has earned it.' }, + { k: 'Send spacing', v: '600s minimum', why: 'At least ten minutes between two sends from the same mailbox. No bursts.' }, + { k: 'Business hours', v: 'recipient TZ', why: 'Steps land inside the recipient working window, not the sender clock.' }, ]; + +// --- Templating fields ------------------------------------------------------- +const mergeFields = ['{{.FirstName}}', '{{.Company}}', '{{.Role}}', '{{.Trigger}}', '{{.City}}']; + +// --- FAQ --------------------------------------------------------------------- +const faq = [ + ['Can a branch route on more than one signal?', + 'Each branch takes one path on one signal: opened, clicked, replied, or a random share. Chain branches to combine them. The then and else paths can each point at any step, an action, or a stop.'], + ['What does "instant" mean on a branch?', + 'Reply, open and click can route the moment they happen instead of waiting for the next scheduled step. A reply can pull the contact off the sequence and notify the owner within seconds.'], + ['How do A/B variants get chosen?', + 'Each contact is assigned a variant at the step by a stable weighted split, so a retry always lands on the same copy. We report which variant earned the reply.'], + ['Where does a sequence actually send from?', + 'A pool of your mailboxes, not one. The scheduler round-robins across the pool under each mailbox cap, the 600s spacing rule, and its current health band, so no single mailbox carries the campaign.'], + ['What stops a sequence for a contact?', + 'A reply, a met goal, a bounce, an unsubscribe, a STOP reply, or a manual suppression. Suppression is workspace-wide, so another sequence never picks the contact back up by accident.'], +]; + +const totalToday = senderPool.reduce((a, m) => a + m.today, 0); +const totalCap = senderPool.reduce((a, m) => a + m.cap, 0); ---
-
+ + + +
Campaigns - Sequences as configuration, not a builder UI + A visual builder for branching sequences

- 12 steps. 4 variants.
One source of truth. + Draw the whole sequence,
branch by branch.

- Multi-step sequences with reply-aware branching, A/B variants, and business-hour delays in the recipient time zone. Edit in the visual builder or commit the YAML. + Email steps, waits, and branches that route every contact on what they actually do: opened, clicked, replied. Add A/B variants to any step, then send the whole thing through a pool of mailboxes in the recipient time zone.

@@ -89,226 +193,454 @@ const faq = [ - - Read the cold-email rules + + See the builder
- -
-
-
+ - -
-
-
acme-fy26-q2
- - - Running + +
+
+
+
The builder
+

+ The sequence is a map, not a list. +

+

+ You lay the whole follow-up out on a canvas. Every email is a step, every gap is a wait you set in days, and a branch sends each contact down its own path the moment they open, click, or reply. This is the real builder, drawn out. +

+
+ + +
+ + +
+
+ + Add step + + + + Stop on reply + + + - v3 · 412 recipients
-
- 6 steps - - 8 active variants - - 4 sender mailboxes +
- -
-
    - {sequence.map((s, i) => { - const st = kindStyle(s.kind); - return ( -
  1. - -
    - {i > 0 &&
    } -
    - {s.idx} -
    + +
    +
    + + +
    +
    +
    + + First touch + Start +
    +
    +
    Email
    +
    {`Quick question, {{.FirstName}}`}
    +
    +
    + On reply +
    +
    +
    + + +
    + + wait 2 days + +
    + + +
    +
    + + if + opened ≥ 1? + instant +
    +
    + + + + + +
    + + +
    +
    + then · opened + +
    + +
    +
    + + Case study nudge
    -
    {s.delay}
    - - {st.label} +
    +
    Email
    +
    {`Saw {{.Company}} shipped {{.Trigger}}`}
    +
    +
    + On reply +
    +
    + +
    wait 3 days
    + + +
    +
    + + Last call +
    +
    +
    Email
    +
    Should I close this out?
    +
    +
    + +
    + + +
    + + Ends here -
    -
    {s.label}
    -
    {s.note}
    +
    +
    + + +
    +
    + else · no open · 4d + +
    + +
    +
    + + Move deal stage
    -
    - {s.variants > 0 - ? {s.variants} {s.variants === 1 ? 'variant' : 'variants'} - : — - } +
    +
    Action
    +
    HubSpot · to Nurture
    -
  2. - ); - })} -
+
+ +
+ + +
+ + Ends here + +
+
+ +
+
+
+ + +
+ 4 steps · 1 branch · 1 action +
-
-
-
-
Variables · fallbacks
+
+
+
+
Step types

- Real personalisation. Explicit fallbacks. + Four kinds of node. +

+

+ Everything on the canvas is one of four nodes. Drop them in, wire them up, and the sequence runs exactly as it reads. +

+
+ +
+ {stepTypes.map((s) => ( +
+
+ + + +
+
{s.name}
+
{s.tag}
+
+
+

{s.desc}

+
+ ))} +
+
+
+ + +
+
+
+
Branching & timing
+

+ Route on what they do.

- Variables pull from any contact field, including custom ones and CRM enrichment. Fallback chains are explicit so a missing field never embarrasses you. + A branch takes one signal and sends the contact down its own path. Reply, open, and click can fire instantly, the moment the event lands, instead of waiting for the next scheduled step. The rest evaluate at the step on a window you set.

    -
  • Pipe syntax chains multiple fallbacks.
  • -
  • Conditional blocks for sender-specific signatures.
  • -
  • Linter flags any token that has no fallback.
  • +
  • Then and else can each point at any step, an action, or a stop.
  • +
  • Waits are set per step, from 0 to 60 days, counted in business days.
-
-
- - - - step-01.body.txt +
+
+
Signal
+
Timing
+
What it catches
-
Hi {{ first_name | fallback: "there" }},
-
-Saw {{ company.name }} just {{
-  news.summary | fallback: news.last_round
-              | fallback: "raised your seed round"
-}}.
-
-Quick question on {{
-  custom.pain | fallback: custom.title_pain
-              | fallback: "outbound"
-}} since you took over as {{ title }}?
-
-{% if sender.role == "founder" %}
-Happy to share the way we approached this for our team.
-{% else %}
-Happy to share a quick walkthrough if useful.
-{% endif %}
-
-— {{ sender.first_name }}
+ {routing.map((r) => ( +
+
+ + {r.sig} +
+
+ {r.instant + ? instant + : window + } +
+
{r.desc}
+
+ ))}
+
+
+
+
Templating
+

+ Real personalisation, with safe defaults. +

+

+ Every step is a Go template, the same syntax used across the rest of Warmbly. Pull a field with a leading dot, give it a default so a blank never embarrasses you, and wrap an optional line in an if so it only shows when the field is set. +

+ +
+
Available fields
+
+ {mergeFields.map((f) => ( + {f} + ))} +
+
+
+ +
+ +
+
+ + + + first touch · body +
+
+
Hi {`{{.FirstName}}`},
+
+
Saw {`{{if .Trigger}}`}{`{{.Trigger}}`} at {`{{end}}`}{`{{ .Company | default "your team" }}`} and the {`{{.Role}}`} role lined up with what we tend to help land.
+
+
Worth a 20-minute look next week?
+
+
Dana
+
+
+ + +
+
+ + Renders for Maya, with no Trigger on file +
+
+
Hi Maya,
+
+
Saw Northwind and the VP RevOps role lined up with what we tend to help land.
+
+
Worth a 20-minute look next week?
+
+
Dana
+
+

+ Her Trigger was blank, so the if skips the "at" clause and the sentence still reads clean. +

+
+
+
+
+ + +
+
+
+
A/B variants
+

+ Test the copy on any step. +

+

+ Add variants to an email step and the volume splits by weight. Each contact is pinned to one variant by a stable split, so a retry never flips them, and we report which one earned the reply. +

+
    +
  • Weights from 1 to 100, the original always carries the rest.
  • +
  • Pause a losing variant without touching the rest of the step.
  • +
+
+ +
+
+ +
+
A/B variants
+
3 variants, split by weight
+
+
+ + +
+
+
+
+
+
+
+ + +
+ {variants.map((v, i) => ( +
+ +
+
+ {v.name} + {v.original && original} +
+
{v.subject}
+
+ {v.weight}% +
+ ))} +
+
+
+
+ +
Sender pool
-

- One campaign, many mailboxes. +

+ One sequence, many mailboxes.

-

- A sequence sends through a pool of mailboxes, not one. The scheduler round-robins recipients across the pool with awareness of each mailbox's daily cap and current health band. No single mailbox carries the campaign. +

+ A sequence sends through a pool of mailboxes, not one. The scheduler round-robins contacts across the pool with an eye on each mailbox daily cap and current health band, so no single mailbox ever carries the campaign.

- -
- - - - - - - - - - - - CAMPAIGN - acme-fy26-q2 - - - - {senderPool.map((m, i) => { - const yTop = 10 + i * 50; - return ( - - - {m.addr} - {m.role} · {m.today}/{m.cap} today - - - - - - - ); - })} - +
+
Mailbox
+
Role
+
Today
+
Cap
+
Headroom
- - -
-
-
Mailbox
-
Role
-
Today
-
Cap
-
Headroom
-
- {senderPool.map((m) => { - const pct = Math.round((m.today / m.cap) * 100); - return ( -
-
{m.addr}
-
{m.role}
-
-
-
-
+ {senderPool.map((m) => { + const pct = Math.round((m.today / m.cap) * 100); + return ( +
+
{m.addr}
+
{m.role}
+
+
+
-
{m.cap}
-
{m.cap - m.today}
- ); - })} -
- -
- Round-robin · health-aware · cap-aware - Pool headroom: 200 sends today +
{m.cap}
+
{m.cap - m.today}
+
+ ); + })} +
+ Round-robin · health-aware · cap-aware + Pool headroom: {totalCap - totalToday} sends today
Auto-stop rules

- Six reasons we stop sending to a recipient. + Six reasons a contact stops getting mail.

- Suppression is propagated across every sequence in the workspace. A bounced contact never gets retried by another sequence by accident. + Suppression carries across every sequence in the workspace. A bounced or opted-out contact never gets picked back up by another sequence by accident.

@@ -331,7 +663,7 @@ Happy to share a quick walkthrough if useful.
@@ -341,7 +673,7 @@ Happy to share a quick walkthrough if useful. The numbers a sequence respects.

- Hard limits in the codebase. Override what you can in settings, but the per-mailbox cap is enforced server-side regardless. + Hard limits from the codebase. Override what you can per step, but the per-mailbox cap is enforced server-side regardless.

@@ -355,7 +687,7 @@ Happy to share a quick walkthrough if useful.

{d.why}

-
{d.v}
+
{d.v}
))} @@ -368,14 +700,14 @@ Happy to share a quick walkthrough if useful.
-
FAQ
+
Campaign FAQ

- Four operational questions. + Five questions about how sequences run.

@@ -418,8 +750,45 @@ Happy to share a quick walkthrough if useful. + + +