mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-13 08:02:34 +00:00
feat: redesign the warmup, deliverability, sending and campaigns pages with dashboard-faithful scenes, documented thresholds and defaults, and phone layouts that stack the health bands and sending posture instead of squeezing them into columns
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
---
|
||||
import ScaledStage from '../../ScaledStage.astro';
|
||||
import Glyph from '../Glyph.astro';
|
||||
import { WINDOW, EYEBROW } from '../../../lib/scene';
|
||||
|
||||
/**
|
||||
* Settings > ESP matching (preferences/EspCoveragePanel.tsx): how many of the
|
||||
* campaign's leads are on Google, Microsoft and other providers, and which
|
||||
* mailboxes serve each, with Prefer same on.
|
||||
*/
|
||||
const rows = [
|
||||
{ mark: 'google', label: 'Google recipients', leads: '742', via: [{ mark: 'google', n: 2 }] },
|
||||
{ mark: 'microsoft', label: 'Microsoft recipients', leads: '402', via: [{ mark: 'microsoft', n: 1 }] },
|
||||
] as const;
|
||||
---
|
||||
<ScaledStage w={460} h={345} photo="sheer-peach">
|
||||
<div class={`${WINDOW} left-[24px] top-[24px] w-[460px] h-[340px]`}>
|
||||
<div class="px-4 pt-3.5 pb-3 pr-10 flex items-start gap-3 border-b border-slate-200/60">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-[12.5px] font-medium text-slate-900">ESP matching</p>
|
||||
<p class="mt-0.5 text-[11px] leading-relaxed text-slate-500">Send Google recipients from Google mailboxes and Microsoft from Microsoft.</p>
|
||||
</div>
|
||||
<span class="inline-flex items-center h-5 px-2 rounded text-[10px] uppercase tracking-[0.08em] font-medium bg-sky-50 text-sky-700">Prefer same</span>
|
||||
</div>
|
||||
<div class="px-4 pt-3 pr-10 space-y-2">
|
||||
<div class={EYEBROW}>Coverage</div>
|
||||
{rows.map((r) => (
|
||||
<div class="flex items-center gap-3 rounded-lg border border-slate-200 bg-white px-3 py-2.5">
|
||||
<span class="size-8 shrink-0 rounded-md border border-slate-200 bg-white flex items-center justify-center"><Glyph mark={r.mark} size={16} /></span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[12.5px] font-medium leading-tight text-slate-800">{r.label} <span class="font-normal text-slate-400">· {r.leads} leads</span></div>
|
||||
<div class="mt-1.5 flex items-center gap-1.5">
|
||||
<span class="text-[10px] uppercase tracking-[0.1em] text-slate-400">via</span>
|
||||
{r.via.map((v) => (
|
||||
<span class="inline-flex h-6 items-center gap-1 rounded-md border border-sky-200 bg-sky-50/60 pl-1 pr-1.5"><Glyph mark={v.mark} size={13} /><span class="text-[11px] tabular-nums text-slate-700">×{v.n}</span></span>
|
||||
))}
|
||||
<span class="inline-flex h-6 items-center gap-1 rounded-md border border-slate-200 bg-slate-50 pl-1 pr-1.5"><Glyph mark="imap" size={12} /><span class="text-[11px] tabular-nums text-slate-500">×1</span><span class="ml-0.5 text-[9px] uppercase tracking-[0.1em] text-slate-400">any</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="inline-flex h-5 items-center gap-1.5 rounded px-2 text-[10px] font-medium uppercase tracking-[0.08em] bg-emerald-50 text-emerald-700"><span class="size-1.5 rounded-full bg-emerald-500"></span>Covered</span>
|
||||
</div>
|
||||
))}
|
||||
<div class="flex items-center gap-3 rounded-lg border border-slate-200 bg-white px-3 py-2.5">
|
||||
<span class="size-8 shrink-0 rounded-md border border-slate-200 bg-slate-50 flex items-center justify-center text-slate-400"><Glyph name="globe" size={15} /></span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[12.5px] font-medium leading-tight text-slate-800">Other providers <span class="font-normal text-slate-400">· 140 leads</span></div>
|
||||
<div class="mt-1.5 flex items-center gap-1.5"><span class="text-[10px] uppercase tracking-[0.1em] text-slate-400">via</span><span class="text-[11px] text-slate-400">Any mailbox in the pool</span></div>
|
||||
</div>
|
||||
<span class="inline-flex h-5 items-center gap-1.5 rounded px-2 text-[10px] font-medium uppercase tracking-[0.08em] bg-slate-100 text-slate-500"><span class="size-1.5 rounded-full bg-slate-400"></span>Any</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScaledStage>
|
||||
@@ -0,0 +1,230 @@
|
||||
---
|
||||
import ScaledStage from '../../ScaledStage.astro';
|
||||
import Glyph from '../Glyph.astro';
|
||||
import Ico from './Ico.astro';
|
||||
import { WINDOW, FLOAT, EYEBROW, TAB } from '../../../lib/scene';
|
||||
|
||||
/**
|
||||
* A running campaign's Steps tab (web/src/app/app/campaigns/[id]/layout.tsx
|
||||
* and sequences/CampaignFlow.tsx): the header, the tab bar, and the flow
|
||||
* canvas with its StepCards, waits beside the lines, branch pills, + buttons
|
||||
* and zoom controls. Contacts travel the paths while a teammate works on a
|
||||
* follow-up, and Today's sending plan (SendPlanCard) floats beside it.
|
||||
*/
|
||||
const tabs = [
|
||||
{ label: 'Overview', icon: 'barChart' },
|
||||
{ label: 'Leads', icon: 'users' },
|
||||
{ label: 'Steps', icon: 'listChecks', active: true },
|
||||
{ label: 'Schedule', icon: 'calendar' },
|
||||
{ label: 'Settings', icon: 'settings2' },
|
||||
] as const;
|
||||
|
||||
// Tree coordinates, laid out the way layoutFlow places them: 232x56 cards,
|
||||
// three columns 264 apart, waits beside the line into a step.
|
||||
const CW = 232;
|
||||
const C = [116, 380, 644];
|
||||
const TONE = {
|
||||
sky: 'bg-sky-50 text-sky-600 ring-sky-100',
|
||||
violet: 'bg-violet-50 text-violet-600 ring-violet-100',
|
||||
emerald: 'bg-emerald-50 text-emerald-600 ring-emerald-100',
|
||||
};
|
||||
type Card = { x: number; y: number; icon: string; tone: string; title: string; kicker: string; summary: string; peer?: boolean };
|
||||
const cards: Card[] = [
|
||||
{ x: C[1] - CW / 2, y: 0, icon: 'login', tone: TONE.violet, title: 'Contact enters the campaign', kicker: 'Start', summary: 'First email right away' },
|
||||
{ x: C[1] - CW / 2, y: 84, icon: 'mail', tone: TONE.sky, title: 'Quick question', kicker: 'First email', summary: '3 variants' },
|
||||
{ x: C[0] - CW / 2, y: 228, icon: 'handshake', tone: TONE.emerald, title: 'Create a deal', kicker: 'Action', summary: 'Stage: Interested' },
|
||||
{ x: C[0] - CW / 2, y: 320, icon: 'send', tone: TONE.sky, title: 'Notify the team', kicker: 'Action', summary: 'Fires your webhooks' },
|
||||
{ x: C[1] - CW / 2, y: 228, icon: 'mail', tone: TONE.sky, title: 'Case study', kicker: 'Reply in thread', summary: 'How Ramp cut onboarding' },
|
||||
{ x: C[2] - CW / 2, y: 228, icon: 'mail', tone: TONE.sky, title: 'Follow-up', kicker: 'Reply in thread', summary: 'Bumping this up', peer: true },
|
||||
{ x: C[2] - CW / 2, y: 320, icon: 'mail', tone: TONE.sky, title: 'Last note', kicker: 'Reply in thread', summary: 'Closing the loop' },
|
||||
];
|
||||
const branch = (sx: number, sy: number, tx: number, ty: number) => {
|
||||
const busY = sy + 20;
|
||||
if (sx === tx) return `M${sx},${sy} L${tx},${ty}`;
|
||||
const r = 10;
|
||||
const dir = tx >= sx ? 1 : -1;
|
||||
return `M${sx},${sy} L${sx},${busY - r} Q${sx},${busY} ${sx + dir * r},${busY} L${tx - dir * r},${busY} Q${tx},${busY} ${tx},${busY + r} L${tx},${ty}`;
|
||||
};
|
||||
const lines = [
|
||||
`M${C[1]},56 L${C[1]},84`,
|
||||
branch(C[1], 140, C[0], 228),
|
||||
branch(C[1], 140, C[1], 228),
|
||||
branch(C[1], 140, C[2], 228),
|
||||
`M${C[0]},284 L${C[0]},320`,
|
||||
`M${C[0]},376 L${C[0]},398`,
|
||||
`M${C[1]},284 L${C[1]},306`,
|
||||
`M${C[2]},284 L${C[2]},320`,
|
||||
`M${C[2]},376 L${C[2]},398`,
|
||||
];
|
||||
const pills = [
|
||||
{ x: C[0], label: 'Replied: positive', instant: true, cls: 'bg-emerald-50 text-emerald-700 ring-emerald-200/80' },
|
||||
{ x: C[1], label: 'Opened within 3d', instant: true, cls: 'bg-emerald-50 text-emerald-700 ring-emerald-200/80' },
|
||||
{ x: C[2], label: 'Otherwise', instant: false, cls: 'bg-white text-slate-500 ring-slate-200' },
|
||||
];
|
||||
const waits = [
|
||||
{ x: C[2], y: 210, label: '3 days' },
|
||||
{ x: C[2], y: 302, label: '4 days' },
|
||||
];
|
||||
const adds: [number, number][] = [[C[0], 409], [C[1], 317], [C[2], 409]];
|
||||
|
||||
// Dots ride these: most contacts wait for the follow-ups, a few branch off.
|
||||
const pFollow = `M${C[1]},28 L${C[1]},160 L${C[2]},160 L${C[2]},256 L${C[2]},348 L${C[2]},398`;
|
||||
const pReply = `M${C[1]},28 L${C[1]},160 L${C[0]},160 L${C[0]},256 L${C[0]},348`;
|
||||
const pOpen = `M${C[1]},28 L${C[1]},160 L${C[1]},256 L${C[1]},306`;
|
||||
|
||||
// Today's sending plan: four mailboxes whose caps add up to 200, one still
|
||||
// easing out of warmup, which is the limit that sets the day.
|
||||
const mailboxes = [
|
||||
{ addr: 'alex@northwind.dev', sub: 'gap 10m', sent: 23, cap: 50 },
|
||||
{ addr: 'sam@northwind.io', sub: 'gap 10m', sent: 19, cap: 50 },
|
||||
{ addr: 'ops@northwind.co', sub: 'gap 10m', sent: 14, cap: 50 },
|
||||
{ addr: 'mia@northwind.dev', sub: 'easing out of warmup · 3 clean days to 50', sent: 6, cap: 36, of: 50 },
|
||||
];
|
||||
---
|
||||
<ScaledStage w={1240} h={740} photo="sheer-blue">
|
||||
<div class={`${WINDOW} left-[48px] top-[40px] w-[1220px] h-[720px]`}>
|
||||
<!-- Campaign header -->
|
||||
<div class="px-5 pt-4 pb-3 flex items-start gap-3">
|
||||
<div class="min-w-0">
|
||||
<span class="inline-flex items-center gap-1 h-6 -ml-1.5 px-1.5 mb-1 rounded-md text-[11.5px] text-slate-500"><Ico name="arrowLeft" size={12} />Campaigns</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[18px] font-semibold text-slate-900">Series A founders, Q3</span>
|
||||
<span class="inline-flex items-center h-5 px-2 rounded-md border text-[10px] uppercase tracking-[0.12em] font-medium bg-emerald-50 text-emerald-700 border-emerald-200">running</span>
|
||||
<span class="ml-1 flex -space-x-1.5">
|
||||
<span class="size-5 rounded-full bg-violet-100 text-violet-700 ring-2 ring-white text-[9px] font-semibold flex items-center justify-center">MC</span>
|
||||
<span class="size-5 rounded-full bg-amber-100 text-amber-700 ring-2 ring-white text-[9px] font-semibold flex items-center justify-center">JR</span>
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-[11px] text-slate-400 font-mono mt-1">cmp_8fK2pQ7rXa19</p>
|
||||
</div>
|
||||
<div class="ml-auto pr-10 flex items-center gap-1.5">
|
||||
<span class="h-7 px-2.5 rounded-md bg-sky-600 text-white text-[12px] font-medium inline-flex items-center gap-1.5"><Glyph name="pause" size={13} />Pause</span>
|
||||
<span class="size-7 rounded-md border border-slate-200 text-slate-500 inline-flex items-center justify-center"><Glyph name="more" size={14} /></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-3 flex items-center gap-1 border-b border-slate-200">
|
||||
{tabs.map((t) => (
|
||||
<span class={`${TAB} ${'active' in t ? 'font-medium text-slate-900' : 'text-slate-500'}`}>
|
||||
<Ico name={t.icon} size={14} />{t.label}
|
||||
{'active' in t && <span class="absolute left-1.5 right-1.5 -bottom-px h-0.5 rounded-full bg-sky-600"></span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="p-5">
|
||||
<div class="relative h-[494px] flex flex-col overflow-hidden rounded-md border border-slate-200 bg-white">
|
||||
<div class="flex min-h-10 items-center gap-x-4 border-b border-slate-200 px-3 py-1.5">
|
||||
<span class="text-[11.5px] text-slate-600">Stop on reply</span>
|
||||
<span class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full bg-sky-600"><span class="inline-block size-4 translate-x-4 rounded-full bg-white shadow-sm"></span></span>
|
||||
<span class="ml-auto pr-10 inline-flex items-center gap-1.5 text-[11px] text-slate-400"><Ico name="users" size={12} /><span class="font-mono tabular-nums text-slate-600" data-tick="412,436,2600">412</span> in flow · 1,284 leads</span>
|
||||
</div>
|
||||
|
||||
<div class="relative flex-1 bg-slate-50/40" style="background-image: radial-gradient(circle, #dde3eb 1px, transparent 1.2px); background-size: 20px 20px;">
|
||||
<div class="absolute left-[40px] top-[16px] w-[760px] h-[420px]">
|
||||
<svg class="absolute inset-0 overflow-visible" width="760" height="420" fill="none">
|
||||
{lines.map((d) => <path d={d} stroke="#cbd5e1" stroke-width="1.25" />)}
|
||||
</svg>
|
||||
{pills.map((p) => (
|
||||
<span class={`absolute whitespace-nowrap rounded-full px-2 text-[11px] font-medium leading-[18px] ring-1 ring-inset ${p.cls}`} style={`left:${p.x}px; top:184px; transform: translate(-50%, -50%);`}>
|
||||
{p.label}{p.instant && <span class="ml-1 rounded-sm bg-violet-100 px-1 text-[9.5px] font-semibold uppercase tracking-[0.06em] text-violet-700">instant</span>}
|
||||
</span>
|
||||
))}
|
||||
{waits.map((n) => (
|
||||
<span class="absolute inline-flex items-center gap-1 whitespace-nowrap text-[11px] text-slate-400" style={`left:${n.x + 12}px; top:${n.y}px; transform: translateY(-50%);`}><Glyph name="clock" size={12} />{n.label}</span>
|
||||
))}
|
||||
{adds.map(([x, y]) => (
|
||||
<span class="absolute flex size-[22px] items-center justify-center rounded-full border border-slate-200 bg-white text-slate-400" style={`left:${x}px; top:${y}px; transform: translate(-50%, -50%);`}><Glyph name="plus" size={12} /></span>
|
||||
))}
|
||||
{cards.map((c) => (
|
||||
<div class={`absolute flex h-[56px] items-center gap-2.5 rounded-xl border bg-white pl-2.5 pr-4 shadow-[0_1px_2px_rgba(15,23,42,0.04)] ${c.peer ? 'border-violet-400 ring-[3px] ring-violet-100' : 'border-slate-200/90'}`} style={`left:${c.x}px; top:${c.y}px; width:${CW}px`}>
|
||||
<span class={`inline-flex size-8 shrink-0 items-center justify-center rounded-lg ring-1 ring-inset ${c.tone}`}><Ico name={c.icon as never} size={16} /></span>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-[13px] font-medium leading-5 text-slate-900">{c.title}</div>
|
||||
<div class="truncate text-[11.5px] leading-4 text-slate-400">{c.kicker}<span class="text-slate-500"> · {c.summary}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{[0, 1, 2].map((k) => (
|
||||
<span class="fh-dot fh-follow" style={`offset-path: path('${pFollow}'); animation-delay: ${k * -3.4}s;`}></span>
|
||||
))}
|
||||
<span class="fh-dot fh-reply" style={`offset-path: path('${pReply}'); animation-delay: -1.6s;`}></span>
|
||||
<span class="fh-dot fh-open" style={`offset-path: path('${pOpen}'); animation-delay: -6.1s;`}></span>
|
||||
|
||||
<!-- A teammate on the same canvas: their cursor and the step they selected. -->
|
||||
<div class="fh-peer absolute left-[700px] top-[272px]">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M2 1.5 L13.5 7.2 L8.2 8.6 L6 14 Z" fill="#8b5cf6" stroke="#fff" stroke-width="1.2" stroke-linejoin="round" /></svg>
|
||||
<span class="absolute left-3.5 top-3.5 whitespace-nowrap rounded-md bg-violet-500 px-1.5 py-0.5 text-[10.5px] font-medium text-white">Maya</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute left-3 bottom-3 flex items-center gap-0.5 rounded-lg border border-slate-200 bg-white/95 p-0.5 shadow-sm text-slate-500">
|
||||
<span class="inline-flex size-7 items-center justify-center"><Ico name="minus" size={14} /></span>
|
||||
<span class="inline-flex h-7 min-w-11 items-center justify-center px-1 text-[11.5px] tabular-nums text-slate-600">100%</span>
|
||||
<span class="inline-flex size-7 items-center justify-center"><Glyph name="plus" size={14} /></span>
|
||||
<span class="mx-0.5 h-4 w-px bg-slate-200"></span>
|
||||
<span class="inline-flex size-7 items-center justify-center"><Ico name="maximize" size={14} /></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Today's sending plan, opened to each mailbox's day. -->
|
||||
<div class={`${FLOAT} left-[876px] top-[206px] w-[340px] overflow-hidden`}>
|
||||
<div class="px-4 pt-3 pb-2.5 border-b border-slate-200/70">
|
||||
<div class="flex items-baseline gap-1.5">
|
||||
<span class="text-[20px] font-semibold leading-none tabular-nums text-slate-900">186</span>
|
||||
<span class="text-[11.5px] text-slate-600">today</span>
|
||||
<span class="ml-auto font-mono text-[11px] tabular-nums text-slate-400"><span data-tick="62,70,3200">62</span> sent · 124 to go</span>
|
||||
</div>
|
||||
<div class="mt-2 h-1 rounded-full bg-slate-100 overflow-hidden"><div class="h-full rounded-full bg-sky-500" style="width:33%"></div></div>
|
||||
<p class="mt-2 text-[11.5px] leading-snug text-slate-500">Easing out of warmup is holding it below the 200 your settings allow.</p>
|
||||
<p class="mt-1.5 inline-flex items-center gap-1.5 text-[11px] text-slate-500"><Glyph name="clock" size={12} class="text-slate-400" />Window open until 17:00 <span class="text-slate-400">· London</span></p>
|
||||
</div>
|
||||
<div class="px-4 py-2.5">
|
||||
<div class={`${EYEBROW} mb-1`}>4 mailboxes</div>
|
||||
<div class="divide-y divide-slate-100">
|
||||
{mailboxes.map((m, i) => (
|
||||
<div class="fh-mb -mx-2 px-2 py-1.5 rounded flex items-center gap-2" style={`--i:${i}`}>
|
||||
<span class="flex-1 min-w-0">
|
||||
<span class="block truncate text-[12px] text-slate-900">{m.addr}</span>
|
||||
<span class="block truncate text-[10.5px] text-slate-400">{m.sub}</span>
|
||||
</span>
|
||||
<span class="shrink-0 font-mono text-[11px] tabular-nums text-slate-500">{m.sent}<span class="text-slate-300"> / </span><span class="text-slate-700">{m.cap}</span>{m.of && <span class="text-slate-400"> of {m.of}</span>}</span>
|
||||
<span class="shrink-0 inline-flex h-5 items-center rounded px-1.5 text-[10px] font-medium ring-1 bg-emerald-50 text-emerald-700 ring-emerald-200">Sending</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScaledStage>
|
||||
|
||||
<style>
|
||||
.fh-dot {
|
||||
position: absolute; left: 0; top: 0; width: 10px; height: 10px; border-radius: 9999px;
|
||||
background: #0ea5e9; box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.2); offset-rotate: 0deg; opacity: 0;
|
||||
animation: fh-move 10.2s linear infinite;
|
||||
}
|
||||
.fh-reply { background: #10b981; box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2); animation-duration: 9s; }
|
||||
.fh-open { background: #10b981; box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2); animation-duration: 7s; }
|
||||
@keyframes fh-move {
|
||||
0% { offset-distance: 0%; opacity: 0; }
|
||||
6%, 90% { opacity: 1; }
|
||||
100% { offset-distance: 100%; opacity: 0; }
|
||||
}
|
||||
.fh-peer { animation: fh-peer 8s ease-in-out infinite; }
|
||||
@keyframes fh-peer {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
35% { transform: translate(-46px, 10px); }
|
||||
60% { transform: translate(-20px, -6px); }
|
||||
}
|
||||
/* The next send rotates through the mailboxes. */
|
||||
.fh-mb { animation: fh-turn 8s linear infinite; animation-delay: calc(var(--i) * 2s); }
|
||||
@keyframes fh-turn {
|
||||
0%, 2% { background: transparent; }
|
||||
5%, 20% { background: rgb(240 249 255); }
|
||||
25%, 100% { background: transparent; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,127 @@
|
||||
---
|
||||
import ScaledStage from '../../ScaledStage.astro';
|
||||
import Glyph from '../Glyph.astro';
|
||||
import Ico from './Ico.astro';
|
||||
import { WINDOW, FLOAT, EYEBROW, TAB } from '../../../lib/scene';
|
||||
|
||||
/**
|
||||
* A campaign that stopped itself: the Settings tab's Auto-pause section
|
||||
* (preferences/CampaignGuardrails) with the reason it tripped, and the live
|
||||
* activity panel (TaskPreview) where the pause lands in the log.
|
||||
*/
|
||||
const tabs = [
|
||||
{ label: 'Overview', icon: 'barChart' },
|
||||
{ label: 'Leads', icon: 'users' },
|
||||
{ label: 'Steps', icon: 'listChecks' },
|
||||
{ label: 'Schedule', icon: 'calendar' },
|
||||
{ label: 'Settings', icon: 'settings2', active: true },
|
||||
] as const;
|
||||
const num = (v: string, suffix: string, w: string) => ({ v, suffix, w });
|
||||
const rates = [
|
||||
{ label: 'Pause above bounce rate', ...num('5', '%', 'w-32') },
|
||||
{ label: 'Pause above complaint rate', ...num('0.1', '%', 'w-32') },
|
||||
{ label: 'Pause below reply rate', ...num('0', '%', 'w-32') },
|
||||
];
|
||||
const sample = [
|
||||
{ label: 'Only after', ...num('50', 'sends', 'w-36') },
|
||||
{ label: 'Measured over the last', ...num('7', 'days', 'w-36') },
|
||||
];
|
||||
const reason = 'Paused automatically: the bounce rate reached 5.4% across 312 sends, at or above the 5.0% limit set for this campaign.';
|
||||
const log = [
|
||||
{ dot: 'bg-slate-300', msg: 'Sent Follow-up to dana@orbit.app from sam@northwind.io', t: '14m ago' },
|
||||
{ dot: 'bg-amber-500', msg: 'Bounced: noah@quanta.dev, mailbox does not exist', t: '11m ago' },
|
||||
{ dot: 'bg-slate-300', msg: 'Sent Quick question to eli@marlow.co from alex@northwind.dev', t: '9m ago' },
|
||||
];
|
||||
---
|
||||
<ScaledStage w={680} h={560} photo="paint-blue">
|
||||
<div class={`${WINDOW} left-[32px] top-[32px] w-[680px] h-[540px]`}>
|
||||
<div class="px-5 pt-3.5 pb-2.5 flex items-center gap-2">
|
||||
<span class="text-[15px] font-semibold text-slate-900">Series A founders, Q3</span>
|
||||
<span class="inline-flex items-center h-5 px-2 rounded-md border text-[10px] uppercase tracking-[0.12em] font-medium bg-slate-100 text-slate-600 border-slate-200">auto-paused</span>
|
||||
<span class="ml-auto mr-10 h-7 px-2.5 rounded-md bg-sky-600 text-white text-[12px] font-medium inline-flex items-center gap-1.5"><Glyph name="play" size={12} />Start</span>
|
||||
</div>
|
||||
<div class="px-3 flex items-center gap-1 border-b border-slate-200">
|
||||
{tabs.map((t) => (
|
||||
<span class={`${TAB} ${'active' in t ? 'font-medium text-slate-900' : 'text-slate-500'}`}>
|
||||
<Ico name={t.icon} size={14} />{t.label}
|
||||
{'active' in t && <span class="absolute left-1.5 right-1.5 -bottom-px h-0.5 rounded-full bg-sky-600"></span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="px-5 py-4 pr-14 space-y-4">
|
||||
<div class="rounded-md border border-amber-100 bg-amber-50/70 px-3 py-2.5 flex gap-2.5">
|
||||
<Ico name="pauseCircle" size={16} class="text-amber-600 mt-0.5" />
|
||||
<div class="min-w-0">
|
||||
<p class="text-[11.5px] leading-relaxed text-amber-900/90">{reason}</p>
|
||||
<p class="mt-1 text-[10.5px] text-amber-800/70">Sep 10, 2026, 3:15 PM</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start justify-between gap-5">
|
||||
<div class="min-w-0">
|
||||
<p class="text-[12.5px] font-medium text-slate-900">Auto-pause</p>
|
||||
<p class="mt-0.5 text-[11px] leading-relaxed text-slate-500">Stop this campaign the moment its bounce, complaint, or reply rate leaves the band below. Checked every 15 minutes.</p>
|
||||
</div>
|
||||
<span class="relative mt-0.5 inline-flex h-[18px] w-8 shrink-0 items-center rounded-full bg-sky-600"><span class="inline-block size-3.5 translate-x-[15px] rounded-full bg-white shadow-sm"></span></span>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border border-slate-200 bg-slate-50/40 p-3.5 space-y-4">
|
||||
{[rates, sample].map((group) => (
|
||||
<div class="flex items-end gap-4">
|
||||
{group.map((f) => (
|
||||
<div>
|
||||
<div class={`${EYEBROW} mb-1.5`}>{f.label}</div>
|
||||
<div class={`relative inline-flex h-7 items-center rounded-md border border-slate-200 bg-white ${f.w}`}>
|
||||
<span class="flex-1 px-2.5 text-[12.5px] tabular-nums text-slate-900">{f.v}</span>
|
||||
<span class="pr-2 text-[11px] text-slate-400">{f.suffix}</span>
|
||||
<span class="flex flex-col self-stretch border-l border-slate-200 text-slate-400">
|
||||
<span class="flex-1 px-1 flex items-center"><Ico name="chevronUp" size={11} /></span>
|
||||
<span class="flex-1 px-1 flex items-center border-t border-slate-200"><Glyph name="chevronDown" size={11} /></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<p class="text-[11px] leading-relaxed text-slate-500">A rate of <b>0</b> turns that rule off. A window of <b>0</b> days measures the campaign’s whole history.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The live panel, where the pause is logged. -->
|
||||
<div class={`${FLOAT} left-[392px] top-[372px] w-[272px] overflow-hidden`}>
|
||||
<div class="px-4 h-11 border-b border-slate-200 flex items-center gap-3">
|
||||
<span class={EYEBROW}>Live activity</span>
|
||||
<span class="inline-flex items-center px-1.5 h-5 rounded-md text-[10.5px] font-medium bg-amber-50 text-amber-700 ring-1 ring-amber-200">Paused</span>
|
||||
<span class="ml-auto inline-flex items-center gap-1.5 text-[11px] text-slate-500">
|
||||
<span class="relative flex size-2"><span class="absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75 animate-ping"></span><span class="relative inline-flex size-2 rounded-full bg-emerald-500"></span></span>Connected
|
||||
</span>
|
||||
</div>
|
||||
<div class="px-4 pt-2.5 pb-1 text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Recent log</div>
|
||||
<div class="divide-y divide-slate-200/60">
|
||||
<div class="gr-new flex items-start gap-2.5 px-4 py-2 bg-rose-50/50">
|
||||
<span class="size-1.5 mt-1.5 shrink-0 rounded-full bg-rose-500"></span>
|
||||
<p class="flex-1 min-w-0 text-[12px] leading-snug text-slate-700">{reason}</p>
|
||||
<span class="shrink-0 mt-0.5 font-mono text-[10.5px] tabular-nums text-slate-400">just now</span>
|
||||
</div>
|
||||
{log.slice(1, 2).map((l) => (
|
||||
<div class="flex items-start gap-2.5 px-4 py-2">
|
||||
<span class={`size-1.5 mt-1.5 shrink-0 rounded-full ${l.dot}`}></span>
|
||||
<p class="flex-1 min-w-0 text-[12px] leading-snug text-slate-600">{l.msg}</p>
|
||||
<span class="shrink-0 mt-0.5 font-mono text-[10.5px] tabular-nums text-slate-400">{l.t}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ScaledStage>
|
||||
|
||||
<style>
|
||||
/* The pause lands at the top of the log on a loop; at rest it is there. */
|
||||
.gr-new { animation: gr-new 10s cubic-bezier(0.2, 0.8, 0.2, 1) infinite; }
|
||||
@keyframes gr-new {
|
||||
0%, 30% { opacity: 0; transform: translateY(-6px); }
|
||||
36%, 94% { opacity: 1; transform: none; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
import { LUCIDE } from '../../../lib/lucide';
|
||||
|
||||
/**
|
||||
* Lucide icons for the campaigns and sending scenes: the shared set in
|
||||
* lib/lucide.ts plus the few these screens draw that it does not carry.
|
||||
*/
|
||||
const EXTRA = {
|
||||
users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
||||
listChecks: '<path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/>',
|
||||
settings2: '<path d="M20 7h-9"/><path d="M14 17H5"/><circle cx="17" cy="17" r="3"/><circle cx="7" cy="7" r="3"/>',
|
||||
arrowLeft: '<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>',
|
||||
minus: '<path d="M5 12h14"/>',
|
||||
maximize: '<path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/>',
|
||||
trash: '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>',
|
||||
eye: '<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/>',
|
||||
pencilLine: '<path d="M12 20h9"/><path d="M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838a.5.5 0 0 1-.62-.62l.838-2.872a2 2 0 0 1 .506-.854z"/>',
|
||||
handshake: '<path d="m11 17 2 2a1 1 0 1 0 3-3"/><path d="m14 14 2.5 2.5a1 1 0 1 0 3-3l-3.88-3.88a3 3 0 0 0-4.24 0l-.88.88a1 1 0 1 1-3-3l2.81-2.81a5.79 5.79 0 0 1 7.06-.87l.47.28a2 2 0 0 0 1.42.25L21 4"/><path d="m21 3 1 11h-2"/><path d="M3 3 2 14l6.5 6.5a1 1 0 1 0 3-3"/><path d="M3 4h8"/>',
|
||||
trophy: '<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/><path d="M18 2H6v7a6 6 0 0 0 12 0V2Z"/>',
|
||||
coffee: '<path d="M10 2v2"/><path d="M14 2v2"/><path d="M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1"/><path d="M6 2v2"/>',
|
||||
sunrise: '<path d="M12 2v8"/><path d="m4.93 10.93 1.41 1.41"/><path d="M2 18h2"/><path d="M20 18h2"/><path d="m19.07 10.93-1.41 1.41"/><path d="M22 22H2"/><path d="m8 6 4-4 4 4"/><path d="M16 18a4 4 0 0 0-8 0"/>',
|
||||
sunset: '<path d="M12 10V2"/><path d="m4.93 10.93 1.41 1.41"/><path d="M2 18h2"/><path d="M20 18h2"/><path d="m19.07 10.93-1.41 1.41"/><path d="M22 22H2"/><path d="m16 6-4 4-4-4"/><path d="M16 18a4 4 0 0 0-8 0"/>',
|
||||
timer: '<line x1="10" x2="14" y1="2" y2="2"/><line x1="12" x2="15" y1="14" y2="11"/><circle cx="12" cy="14" r="8"/>',
|
||||
mailOpen: '<path d="M21.2 8.4c.5.38.8.97.8 1.6v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V10a2 2 0 0 1 .8-1.6l8-6a2 2 0 0 1 2.4 0l8 6Z"/><path d="m22 10-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 10"/>',
|
||||
pointer: '<path d="M14 4.1 12 6"/><path d="m5.1 8-2.9-.8"/><path d="m6 12-1.9 2"/><path d="M7.2 2.2 8 5.1"/><path d="M9.037 9.69a.498.498 0 0 1 .653-.653l11 4.5a.5.5 0 0 1-.074.949l-4.349 1.041a1 1 0 0 0-.74.739l-1.04 4.35a.5.5 0 0 1-.95.074z"/>',
|
||||
cornerUpLeft: '<polyline points="9 14 4 9 9 4"/><path d="M20 20v-7a4 4 0 0 0-4-4H4"/>',
|
||||
userMinus: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="22" x2="16" y1="11" y2="11"/>',
|
||||
download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>',
|
||||
upload: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" x2="12" y1="3" y2="15"/>',
|
||||
chevronUp: '<path d="m18 15-6-6-6 6"/>',
|
||||
pauseCircle: '<circle cx="12" cy="12" r="10"/><line x1="10" x2="10" y1="15" y2="9"/><line x1="14" x2="14" y1="15" y2="9"/>',
|
||||
triangleAlert: '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
|
||||
star: '<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"/>',
|
||||
key: '<path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4"/><path d="m21 2-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/>',
|
||||
lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||
shuffle: '<path d="m18 14 4 4-4 4"/><path d="m18 2 4 4-4 4"/><path d="M2 18h1.973a4 4 0 0 0 3.3-1.7l5.454-8.6a4 4 0 0 1 3.3-1.7H22"/><path d="M2 6h1.972a4 4 0 0 1 3.6 2.2"/><path d="M22 18h-6.041a4 4 0 0 1-3.3-1.8l-.359-.45"/>',
|
||||
list: '<path d="M3 5h.01"/><path d="M3 12h.01"/><path d="M3 19h.01"/><path d="M8 5h13"/><path d="M8 12h13"/><path d="M8 19h13"/>',
|
||||
underline: '<path d="M6 4v6a6 6 0 0 0 12 0V4"/><line x1="4" x2="20" y1="20" y2="20"/>',
|
||||
image: '<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
|
||||
arrowRight: '<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>',
|
||||
copy: '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
|
||||
calendarClock: '<path d="M16 14v2.2l1.6 1"/><path d="M16 2v4"/><path d="M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h6.5"/><path d="M3 10h5"/><path d="M8 2v4"/><circle cx="16" cy="16" r="6"/>',
|
||||
} as const;
|
||||
|
||||
type Name = keyof typeof EXTRA | keyof typeof LUCIDE;
|
||||
interface Props {
|
||||
name: Name;
|
||||
size?: number;
|
||||
class?: string;
|
||||
/** Stroke width, 2 by default as lucide draws it. */
|
||||
stroke?: number;
|
||||
}
|
||||
const { name, size = 14, class: className = '', stroke = 2 } = Astro.props;
|
||||
const body = (EXTRA as Record<string, string>)[name] ?? (LUCIDE as Record<string, string>)[name] ?? '';
|
||||
---
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width={stroke} stroke-linecap="round" stroke-linejoin="round" class={`shrink-0 ${className}`} set:html={body} />
|
||||
@@ -0,0 +1,226 @@
|
||||
---
|
||||
import ScaledStage from '../../ScaledStage.astro';
|
||||
import Glyph from '../Glyph.astro';
|
||||
import Ico from './Ico.astro';
|
||||
import { WINDOW, FLOAT, EYEBROW, TAB, ACTION_GHOST, ACTION_PRIMARY } from '../../../lib/scene';
|
||||
|
||||
/**
|
||||
* A campaign's Leads tab (ContactsTable in its campaign_leads view, columns
|
||||
* from contacts/columns.tsx): linked segments, the status strip with its
|
||||
* filter chips, and each lead's progress, engagement, current step and the
|
||||
* one mailbox that sends their whole sequence. A held lead's panel floats
|
||||
* over the list.
|
||||
*/
|
||||
const tabs = [
|
||||
{ label: 'Overview', icon: 'barChart' },
|
||||
{ label: 'Leads', icon: 'users', active: true },
|
||||
{ label: 'Steps', icon: 'listChecks' },
|
||||
{ label: 'Schedule', icon: 'calendar' },
|
||||
{ label: 'Settings', icon: 'settings2' },
|
||||
] as const;
|
||||
|
||||
type Status = 'active' | 'completed' | 'replied' | 'pending' | 'bounced' | 'paused' | 'unsubscribed';
|
||||
const META: Record<Status, { label: string; text: string; icon?: string }> = {
|
||||
pending: { label: 'Queued', text: 'text-slate-500', icon: 'clock' },
|
||||
active: { label: 'Processing', text: 'text-sky-700' },
|
||||
completed: { label: 'Done', text: 'text-indigo-700', icon: 'check' },
|
||||
replied: { label: 'Replied', text: 'text-emerald-700', icon: 'cornerUpLeft' },
|
||||
bounced: { label: 'Bounced', text: 'text-rose-600', icon: 'triangleAlert' },
|
||||
paused: { label: 'Paused', text: 'text-violet-600', icon: 'pause' },
|
||||
unsubscribed: { label: 'Unsubscribed', text: 'text-slate-400', icon: 'x' },
|
||||
};
|
||||
type Lead = {
|
||||
name: string; email: string; company: string; status: Status; init: string; tint: string;
|
||||
o?: number | 'auto'; c?: number; r?: number; step?: string; sender?: string; last: string; sent: boolean;
|
||||
};
|
||||
const leads: Lead[] = [
|
||||
{ name: 'Jenna Carter', email: 'jenna@acme.io', company: 'Acme', status: 'replied', init: 'JC', tint: 'bg-emerald-100 text-emerald-700', o: 2, c: 1, r: 1, step: 'Follow-up', sender: 'alex@northwind.dev', last: '4m ago', sent: true },
|
||||
{ name: 'Ravi Shah', email: 'ravi@stratech.com', company: 'Stratech', status: 'active', init: 'RS', tint: 'bg-sky-100 text-sky-700', o: 1, step: 'Quick question', sender: 'sam@northwind.io', last: '12m ago', sent: true },
|
||||
{ name: 'Lina Park', email: 'lina@helio.so', company: 'Helio', status: 'paused', init: 'LP', tint: 'bg-violet-100 text-violet-700', o: 1, step: 'Follow-up', sender: 'ops@northwind.co', last: '2h ago', sent: true },
|
||||
{ name: 'Tom Weber', email: 'tom@kraftwerk.de', company: 'Kraftwerk', status: 'active', init: 'TW', tint: 'bg-amber-100 text-amber-700', o: 'auto', step: 'Quick question', sender: 'mia@northwind.dev', last: '38m ago', sent: true },
|
||||
{ name: 'Aman Patel', email: 'aman@ledgerly.com', company: 'Ledgerly', status: 'completed', init: 'AP', tint: 'bg-indigo-100 text-indigo-700', o: 3, c: 1, step: 'Last note', sender: 'alex@northwind.dev', last: 'Sep 3', sent: true },
|
||||
{ name: 'Sofia Rossi', email: 'sofia@verde.it', company: 'Verde', status: 'pending', init: 'SR', tint: 'bg-slate-100 text-slate-600', last: 'Sep 9', sent: false },
|
||||
{ name: 'Noah Brooks', email: 'noah@quanta.dev', company: 'Quanta', status: 'bounced', init: 'NB', tint: 'bg-rose-100 text-rose-700', step: 'Quick question', sender: 'sam@northwind.io', last: 'Sep 8', sent: true },
|
||||
{ name: 'Karim Salah', email: 'karim@fieldnote.co', company: 'Fieldnote', status: 'unsubscribed', init: 'KS', tint: 'bg-slate-100 text-slate-500', o: 1, step: 'Follow-up', sender: 'ops@northwind.co', last: 'Sep 8', sent: true },
|
||||
{ name: 'Maya Lopez', email: 'maya@brightline.io', company: 'Brightline', status: 'pending', init: 'ML', tint: 'bg-slate-100 text-slate-600', last: 'Sep 9', sent: false },
|
||||
];
|
||||
const chips = [
|
||||
{ dot: 'bg-sky-500', label: 'Processing', n: '412', loader: true },
|
||||
{ dot: 'bg-indigo-500', label: 'Done', n: '96' },
|
||||
{ dot: 'bg-emerald-500', label: 'Replied', n: '61' },
|
||||
{ dot: 'bg-slate-300', label: 'Queued', n: '640' },
|
||||
{ dot: 'bg-rose-400', label: 'Bounced', n: '9' },
|
||||
{ dot: 'bg-violet-400', label: 'Paused', n: '14' },
|
||||
{ dot: 'bg-slate-300', label: 'Unsub', n: '52' },
|
||||
];
|
||||
const eng = [
|
||||
{ icon: 'mailOpen', label: 'Opened', n: '380' },
|
||||
{ icon: 'pointer', label: 'Clicked', n: '72' },
|
||||
{ icon: 'cornerUpLeft', label: 'Replied', n: '61' },
|
||||
] as const;
|
||||
// The status bar's shares of the 1,284 leads.
|
||||
const bar = [
|
||||
['bg-sky-500', 412], ['bg-indigo-500', 96], ['bg-emerald-500', 61], ['bg-slate-300', 640],
|
||||
['bg-rose-400', 9], ['bg-violet-400', 14], ['bg-slate-200', 52],
|
||||
] as const;
|
||||
const th = 'px-3 py-2 text-[10px] font-medium text-slate-400 uppercase tracking-[0.14em] text-left';
|
||||
const muted = (s: Status) => s === 'replied' || s === 'bounced' || s === 'unsubscribed';
|
||||
---
|
||||
<ScaledStage w={1240} h={620} photo="frost-blue">
|
||||
<div class={`${WINDOW} left-[48px] top-[44px] w-[1220px] h-[600px]`}>
|
||||
<div class="px-5 pt-3.5 pb-2.5 flex items-center gap-2">
|
||||
<span class="inline-flex items-center gap-1 text-[11.5px] text-slate-500"><Ico name="arrowLeft" size={12} />Campaigns</span>
|
||||
<span class="text-slate-300">/</span>
|
||||
<span class="text-[15px] font-semibold text-slate-900">Series A founders, Q3</span>
|
||||
<span class="inline-flex items-center h-5 px-2 rounded-md border text-[10px] uppercase tracking-[0.12em] font-medium bg-emerald-50 text-emerald-700 border-emerald-200">running</span>
|
||||
</div>
|
||||
<div class="px-3 flex items-center gap-1 border-b border-slate-200">
|
||||
{tabs.map((t) => (
|
||||
<span class={`${TAB} ${'active' in t ? 'font-medium text-slate-900' : 'text-slate-500'}`}>
|
||||
<Ico name={t.icon} size={14} />{t.label}
|
||||
{'active' in t && <span class="absolute left-1.5 right-1.5 -bottom-px h-0.5 rounded-full bg-sky-600"></span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="h-12 px-5 border-b border-slate-200 flex items-center gap-1.5">
|
||||
<div class="h-7 w-56 pl-2 pr-1 rounded-md border border-slate-200 bg-white flex items-center gap-1.5">
|
||||
<Glyph name="search" size={13} class="text-slate-400" /><span class="text-[12.5px] text-slate-400">Search leads…</span>
|
||||
</div>
|
||||
<span class={ACTION_GHOST}><Glyph name="filter" size={12} class="text-slate-400" />Filters</span>
|
||||
<div class="ml-auto pr-12 flex items-center gap-1.5">
|
||||
<span class={ACTION_GHOST}><Ico name="users" size={12} />From contacts</span>
|
||||
<span class={ACTION_GHOST}><Ico name="upload" size={12} />Import</span>
|
||||
<span class={ACTION_GHOST}><Glyph name="layers" size={12} />Segments (2)</span>
|
||||
<span class={ACTION_GHOST}><Ico name="download" size={12} />Export</span>
|
||||
<span class={ACTION_PRIMARY}><Glyph name="plus" size={12} />Add lead</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Linked segments -->
|
||||
<div class="px-5 h-9 border-b border-slate-200/60 flex items-center gap-2">
|
||||
<span class={EYEBROW}>Linked segments</span>
|
||||
{[['Series A, EU', '742/742'], ['Founders hiring sales', '542/542']].map(([n, c]) => (
|
||||
<span class="inline-flex h-6 items-center gap-1.5 rounded-md border border-slate-200 bg-white pl-2 pr-1 text-[11.5px] text-slate-700">
|
||||
<Glyph name="layers" size={11} class="text-slate-400" />{n}<span class="font-mono text-[10.5px] text-slate-400 tabular-nums">{c}</span>
|
||||
<span class="ml-0.5 inline-flex size-4 items-center justify-center text-slate-400"><Ico name="arrowRight" size={11} /></span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<!-- Status strip -->
|
||||
<div class="px-5 py-2.5 border-b border-slate-200/60 flex items-center gap-4">
|
||||
<div class="w-[200px] shrink-0 flex h-1.5 overflow-hidden rounded-full bg-slate-100">
|
||||
{bar.map(([c, n]) => <div class={c} style={`width:${(n / 1284) * 100}%`}></div>)}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-[11px]">
|
||||
{chips.map((s) => (
|
||||
<span class="inline-flex items-center gap-1.5 h-6 px-1.5 -mx-0.5 rounded-md text-slate-500">
|
||||
{s.loader ? <span class="campaign-grid text-sky-600"></span> : <span class={`size-1.5 rounded-full ${s.dot}`}></span>}
|
||||
{s.label}<span class="font-mono tabular-nums text-slate-900">{s.n}</span>
|
||||
</span>
|
||||
))}
|
||||
<span class="h-4 w-px bg-slate-200 mx-0.5"></span>
|
||||
{eng.map((e) => (
|
||||
<span class="inline-flex items-center gap-1.5 h-6 px-1.5 -mx-0.5 rounded-md text-slate-500"><Ico name={e.icon} size={12} class="text-slate-400" />{e.label}<span class="font-mono tabular-nums text-slate-900">{e.n}</span></span>
|
||||
))}
|
||||
</div>
|
||||
<div class="ml-auto pr-12 flex items-center gap-2 text-[10.5px] text-slate-400 tabular-nums">
|
||||
<span class="inline-flex items-center gap-1 font-medium text-emerald-600">
|
||||
<span class="relative flex size-1.5"><span class="absolute inline-flex h-full w-full rounded-full bg-emerald-500 opacity-60 animate-ping"></span><span class="relative inline-flex size-1.5 rounded-full bg-emerald-500"></span></span>Live
|
||||
</span>
|
||||
1,284 leads
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="w-full table-fixed text-left">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-200">
|
||||
<th class="pl-5 pr-2 py-2 w-11"><span class="block size-3.5 rounded-[4px] border border-slate-300"></span></th>
|
||||
<th class={th}>Name</th>
|
||||
<th class={`${th} w-[120px]`}>Company</th>
|
||||
<th class={`${th} w-[128px]`}>Progress</th>
|
||||
<th class={`${th} w-[76px]`}>Opened</th>
|
||||
<th class={`${th} w-[76px]`}>Clicked</th>
|
||||
<th class={`${th} w-[76px]`}>Replied</th>
|
||||
<th class={`${th} w-[132px]`}>Current step</th>
|
||||
<th class={`${th} w-[168px]`}><span class="inline-flex items-center gap-1">Sender<Glyph name="info" size={11} /></span></th>
|
||||
<th class={`${th} w-[150px]`}>Last activity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{leads.map((l) => {
|
||||
const m = META[l.status];
|
||||
const dim = muted(l.status);
|
||||
const engCell = (v: number | 'auto' | undefined, icon: string) =>
|
||||
typeof v === 'number' && v > 0
|
||||
? <span class="inline-flex items-center gap-1 text-[11px] font-medium text-emerald-700 tabular-nums"><Ico name={icon as never} size={12} />{v}</span>
|
||||
: v === 'auto'
|
||||
? <span class="text-[10.5px] text-slate-400">auto</span>
|
||||
: l.sent ? <span class="text-slate-300">—</span> : null;
|
||||
return (
|
||||
<tr class={`h-11 border-b border-slate-200/60 ${l.status === 'active' ? 'bg-sky-50/40' : dim ? 'bg-slate-50/40' : ''} ${l.status === 'paused' ? 'ld-hover' : ''}`}>
|
||||
<td class="pl-5 pr-2"><span class="block size-3.5 rounded-[4px] border border-slate-300 bg-white"></span></td>
|
||||
<td class="px-3">
|
||||
<div class="flex items-center gap-2.5 min-w-0">
|
||||
<span class={`size-6 shrink-0 rounded-full text-[9.5px] font-semibold flex items-center justify-center ${l.tint}`}>{l.init}</span>
|
||||
<div class="min-w-0">
|
||||
<div class={`truncate text-[12.5px] font-medium leading-tight ${dim ? 'text-slate-400' : 'text-slate-900'}`}>{l.name}</div>
|
||||
<div class="truncate font-mono text-[10.5px] leading-tight text-slate-400 flex items-center gap-1"><Glyph name="mail" size={10} />{l.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 truncate text-[12px] text-slate-600">{l.company}</td>
|
||||
<td class="px-3">
|
||||
<span class={`inline-flex items-center gap-1.5 text-[10.5px] font-medium uppercase tracking-[0.08em] ${m.text}`}>
|
||||
{m.icon ? <Ico name={m.icon as never} size={12} /> : <span class="campaign-grid text-sky-600"></span>}{m.label}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3">{engCell(l.o, 'mailOpen')}</td>
|
||||
<td class="px-3">{engCell(l.c, 'pointer')}</td>
|
||||
<td class="px-3">{engCell(l.r, 'cornerUpLeft')}</td>
|
||||
<td class="px-3">
|
||||
{l.step
|
||||
? <span class={`inline-flex items-center h-5 px-1.5 rounded text-[11px] font-medium max-w-full ${dim ? 'bg-slate-100 text-slate-400' : 'bg-sky-100 text-sky-700'}`}><span class="truncate">{l.step}</span></span>
|
||||
: <span class="text-[11px] text-slate-300">Not started</span>}
|
||||
</td>
|
||||
<td class="px-3">
|
||||
{l.sender ? <span class="block truncate text-[11.5px] text-slate-600">{l.sender}</span> : <span class="text-[11px] text-slate-300">Not assigned</span>}
|
||||
</td>
|
||||
<td class="px-3 text-[11.5px] text-slate-500 tabular-nums">{l.last}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- The held lead, opened: an out-of-office hold with its return date. -->
|
||||
<div class={`${FLOAT} left-[866px] top-[398px] w-[340px] overflow-hidden`}>
|
||||
<div class="px-4 py-3 border-b border-slate-200/70 flex items-center gap-2.5">
|
||||
<span class="size-8 shrink-0 rounded-full bg-violet-100 text-violet-700 text-[11px] font-semibold flex items-center justify-center">LP</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-[12.5px] font-medium text-slate-900">Lina Park</div>
|
||||
<div class="truncate text-[10.5px] text-slate-400">Series A founders, Q3 · via ops@northwind.co</div>
|
||||
</div>
|
||||
<span class="inline-flex items-center gap-1 text-[10.5px] font-medium uppercase tracking-[0.08em] text-violet-600"><Glyph name="pause" size={11} />Paused</span>
|
||||
</div>
|
||||
<div class="px-4 py-3 space-y-2">
|
||||
<div class="text-[12.5px] text-slate-800">Out of office · until Sep 15, 9:00 AM</div>
|
||||
<div class="rounded-md bg-slate-50 px-2.5 py-2 text-[11.5px] leading-relaxed text-slate-500">
|
||||
<span class="text-slate-400">Automatic reply:</span> “I’m away and back on 14 September.”
|
||||
</div>
|
||||
<div class="text-[11px] leading-relaxed text-slate-400">Her next step keeps its wait, counted from the day she is back.</div>
|
||||
</div>
|
||||
<div class="px-4 py-2.5 border-t border-slate-200/70 flex items-center gap-1.5">
|
||||
<span class={ACTION_PRIMARY}><Glyph name="play" size={11} />Resume now</span>
|
||||
<span class={ACTION_GHOST}>Stop</span>
|
||||
</div>
|
||||
</div>
|
||||
</ScaledStage>
|
||||
|
||||
<style>
|
||||
/* The held lead's row reads as hovered while its panel is open. */
|
||||
.ld-hover { background: rgb(245 243 255 / 0.7); }
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
import ScaledStage from '../../ScaledStage.astro';
|
||||
import Glyph from '../Glyph.astro';
|
||||
import Ico from './Ico.astro';
|
||||
import { WINDOW, EYEBROW } from '../../../lib/scene';
|
||||
|
||||
/**
|
||||
* Today's sending plan opened to "From your settings to today" (SendPlanCard):
|
||||
* the mailbox caps added up, every limit that took something off, and what
|
||||
* is still to go. The numbers add up the way the card's do.
|
||||
*/
|
||||
const limits = [
|
||||
{ label: 'Campaign daily limit', n: '6 mailboxes', v: 60 },
|
||||
{ label: 'Easing out of warmup', n: '1 mailbox', v: 15 },
|
||||
{ label: 'Used by other campaigns', n: '2 mailboxes', v: 22 },
|
||||
{ label: 'Minimum gap between sends', n: '1 mailbox', v: 9 },
|
||||
];
|
||||
---
|
||||
<ScaledStage w={460} h={345} photo="sheer-sky">
|
||||
<div class={`${WINDOW} left-[24px] top-[24px] w-[460px] h-[340px]`}>
|
||||
<div class="min-h-12 px-4 py-2 flex items-center gap-3 border-b border-slate-200/60">
|
||||
<span class="flex items-baseline gap-1.5">
|
||||
<span class="text-[20px] font-semibold leading-none tabular-nums text-slate-900">194</span>
|
||||
<span class="text-[11.5px] text-slate-600">today</span>
|
||||
</span>
|
||||
<span class="w-16 h-1 rounded-full bg-slate-100 overflow-hidden"><span class="enter-grow-x block h-full rounded-full bg-sky-500" style="width:45%"></span></span>
|
||||
<span class="font-mono text-[11px] tabular-nums text-slate-400">87 sent · 107 to go</span>
|
||||
<span class="ml-auto mr-6 inline-flex items-center gap-1 text-[11px] text-slate-500">Why<span class="rotate-180 inline-flex"><Glyph name="chevronDown" size={11} /></span></span>
|
||||
</div>
|
||||
<div class="px-4 py-3 pr-10">
|
||||
<div class={`${EYEBROW} mb-1.5`}>From your settings to today</div>
|
||||
<div class="divide-y divide-slate-100 text-[12px]">
|
||||
<div class="h-7 flex items-center"><span class="text-slate-700">Mailbox caps added up</span><span class="ml-auto font-mono text-[11.5px] tabular-nums text-slate-700">300</span></div>
|
||||
{limits.map((l, i) => (
|
||||
<div class="enter-fade h-7 flex items-center gap-2" style={`--d:${i * 120}ms`}>
|
||||
<span class="truncate text-slate-700">{l.label}</span>
|
||||
<span class="text-[10.5px] text-slate-400">{l.n}</span>
|
||||
<span class="ml-auto font-mono text-[11.5px] tabular-nums text-rose-600">−{l.v}</span>
|
||||
</div>
|
||||
))}
|
||||
<div class="h-7 flex items-center"><span class="text-slate-700">Sent today</span><span class="ml-auto font-mono text-[11.5px] tabular-nums text-slate-500">−87</span></div>
|
||||
<div class="h-7 flex items-center"><span class="font-medium text-slate-900">Still to go today</span><span class="ml-auto font-mono text-[11.5px] font-medium tabular-nums text-slate-900">107</span></div>
|
||||
</div>
|
||||
<div class="mt-2 inline-flex items-center gap-1.5 text-[11px] text-slate-500"><Ico name="users" size={12} class="text-slate-400" />Leads: 88 due now · 140 later today · 312 waiting on a step</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScaledStage>
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
import ScaledStage from '../../ScaledStage.astro';
|
||||
import Glyph from '../Glyph.astro';
|
||||
import { WINDOW, EYEBROW } from '../../../lib/scene';
|
||||
|
||||
/**
|
||||
* The campaign's Settings: Sending accounts by tag, and Inbox rotation as the
|
||||
* OptionSelect draws it (preferences/CampaignEmails.tsx), Even spacing picked.
|
||||
*/
|
||||
const options = [
|
||||
{ label: 'Even spacing', hint: 'Recommended. Always picks the mailbox that has been idle longest.', on: true },
|
||||
{ label: 'Round-robin', hint: 'Cycles through your mailboxes in order (A, B, C, A).' },
|
||||
{ label: 'Weighted', hint: 'Sends more from your healthiest, higher-limit mailboxes.' },
|
||||
];
|
||||
const boxes = [
|
||||
{ mark: 'google', addr: 'alex@northwind.dev' },
|
||||
{ mark: 'microsoft', addr: 'sam@northwind.io' },
|
||||
{ mark: 'imap', addr: 'ops@northwind.co' },
|
||||
] as const;
|
||||
---
|
||||
<ScaledStage w={460} h={345} photo="lake-motion">
|
||||
<div class={`${WINDOW} left-[24px] top-[24px] w-[460px] h-[340px]`}>
|
||||
<div class="px-4 pt-3.5 pb-3 pr-10 border-b border-slate-200/60">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class={EYEBROW}>Sending accounts</span>
|
||||
<span class="ml-auto inline-flex h-5 items-center rounded bg-sky-50 px-1.5 text-[10.5px] font-medium text-sky-700">tag: outbound</span>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
{boxes.map((b, i) => (
|
||||
<span class="rt-chip inline-flex h-6 items-center gap-1.5 rounded-md border border-slate-200 bg-white pl-1.5 pr-2 text-[11.5px] text-slate-700" style={`--i:${i}`}><Glyph mark={b.mark} size={12} />{b.addr}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-4 pt-3 pr-10">
|
||||
<p class="text-[12.5px] font-medium text-slate-900">Inbox rotation</p>
|
||||
<p class="mt-0.5 text-[11px] leading-relaxed text-slate-500">Picks the mailbox for a lead’s first email. Every follow-up comes from the same address.</p>
|
||||
<div class="mt-2.5 grid gap-1.5">
|
||||
{options.map((o) => (
|
||||
<div class={`flex items-start gap-2.5 rounded-md border px-3 py-2 ${o.on ? 'border-sky-300 bg-sky-50' : 'border-slate-200 bg-white'}`}>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class={`block text-[12px] font-medium leading-snug ${o.on ? 'text-sky-700' : 'text-slate-700'}`}>{o.label}</span>
|
||||
<span class={`mt-0.5 block text-[11px] leading-snug ${o.on ? 'text-sky-600/80' : 'text-slate-400'}`}>{o.hint}</span>
|
||||
</span>
|
||||
<span class={`mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border ${o.on ? 'border-sky-600 bg-sky-600 text-white' : 'border-slate-300 bg-white text-transparent'}`}><Glyph name="check" size={10} /></span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScaledStage>
|
||||
|
||||
<style>
|
||||
/* Whichever mailbox has been idle longest takes the next new lead. */
|
||||
.rt-chip { animation: rt-turn 6s linear infinite; animation-delay: calc(var(--i) * 2s); }
|
||||
@keyframes rt-turn {
|
||||
0%, 2% { border-color: #e2e8f0; background: #fff; }
|
||||
6%, 26% { border-color: #7dd3fc; background: rgb(240 249 255); }
|
||||
32%, 100% { border-color: #e2e8f0; background: #fff; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,109 @@
|
||||
---
|
||||
import ScaledStage from '../../ScaledStage.astro';
|
||||
import Glyph from '../Glyph.astro';
|
||||
import Ico from './Ico.astro';
|
||||
import { WINDOW, FLOAT, EYEBROW } from '../../../lib/scene';
|
||||
|
||||
/**
|
||||
* The Schedule tab (schedule/page.tsx with WeekScheduleGrid): weekly sending
|
||||
* windows, each day on its own, in the campaign's timezone. The First email
|
||||
* setting (CampaignFirstEmail with EntryDelayPicker) floats over it.
|
||||
*/
|
||||
const DAY = 1440;
|
||||
const ABBR = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
const HOURS = [0, 6, 9, 12, 15, 18, 24];
|
||||
const windows: [number, number][][] = [
|
||||
[[540, 720], [780, 1020]],
|
||||
[[540, 1020]],
|
||||
[[510, 720], [840, 1080]],
|
||||
[[540, 1020]],
|
||||
[[540, 900]],
|
||||
[],
|
||||
[],
|
||||
];
|
||||
const BODY = 300;
|
||||
const GUTTER = 40;
|
||||
const y = (m: number) => (m / DAY) * BODY;
|
||||
const label = (h: number) => (h === 0 || h === 24 ? '12a' : h === 12 ? '12p' : h < 12 ? `${h}a` : `${h - 12}p`);
|
||||
const fmt = (m: number) => {
|
||||
const h = Math.floor(m / 60), mm = m % 60;
|
||||
const h12 = h % 12 === 0 ? 12 : h % 12;
|
||||
return `${h12}${mm ? `:${String(mm).padStart(2, '0')}` : ''}${h < 12 ? 'am' : 'pm'}`;
|
||||
};
|
||||
---
|
||||
<ScaledStage w={680} h={560} photo="sheer-cream">
|
||||
<div class={`${WINDOW} left-[32px] top-[32px] w-[680px] h-[540px]`}>
|
||||
<div class="px-4 h-14 flex items-center gap-3 border-b border-slate-200/70">
|
||||
<span class="size-7 rounded-md bg-sky-50 text-sky-600 inline-flex items-center justify-center"><Ico name="calendarClock" size={16} /></span>
|
||||
<div>
|
||||
<div class={EYEBROW}>Weekly sending windows</div>
|
||||
<p class="text-[12.5px] text-slate-700 leading-tight mt-0.5"><span class="font-medium text-slate-900">7</span> windows across <span class="font-medium text-slate-900">5</span> days</p>
|
||||
</div>
|
||||
<span class="ml-auto mr-8 h-7 px-2 rounded-md border border-slate-200 bg-white text-slate-700 inline-flex items-center gap-1.5 text-[12px] font-medium">
|
||||
<Glyph name="globe" size={13} class="text-slate-400" />(UTC+01:00) Europe/London<Glyph name="chevronDown" size={10} class="text-slate-400" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="px-4 py-2.5 flex items-center gap-2 border-b border-slate-200/70 bg-slate-50/30">
|
||||
<span class={`${EYEBROW} mr-0.5`}>Presets</span>
|
||||
{['Mon–Fri 9–5', 'Every day 9–5', 'Clear'].map((p) => <span class="h-7 px-2.5 inline-flex items-center rounded-md border border-slate-200 bg-white text-[11.5px] text-slate-600">{p}</span>)}
|
||||
</div>
|
||||
<div class="px-4 pt-3 pr-12">
|
||||
<div class="flex">
|
||||
<div style={`width:${GUTTER}px`}></div>
|
||||
<div class="flex flex-1 gap-px">
|
||||
{ABBR.map((d, i) => (
|
||||
<div class={`relative flex-1 h-8 flex items-center justify-center rounded-t-md text-[11px] ${windows[i].length ? 'text-sky-700 font-semibold bg-sky-50/60' : 'text-slate-400'}`}>
|
||||
{d}{i === 2 && <span class="absolute bottom-1 size-1 rounded-full bg-sky-500"></span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative" style={`height:${BODY}px`}>
|
||||
{HOURS.map((h) => (
|
||||
<>
|
||||
<span class="absolute left-0 -translate-y-1/2 pr-2 text-right text-[10px] text-slate-400 tabular-nums" style={`top:${y(h * 60)}px; width:${GUTTER}px`}>{label(h)}</span>
|
||||
<div class="absolute right-0 border-t border-slate-100" style={`left:${GUTTER}px; top:${y(h * 60)}px`}></div>
|
||||
</>
|
||||
))}
|
||||
<div class="absolute top-0 bottom-0 right-0 flex gap-px" style={`left:${GUTTER}px`}>
|
||||
{ABBR.map((_, i) => (
|
||||
<div class={`relative flex-1 ${windows[i].length ? 'bg-sky-50/30' : 'bg-slate-50/40'} ${i === 2 ? 'ring-1 ring-inset ring-sky-100' : ''}`}>
|
||||
{windows[i].length === 0 && <span class="absolute inset-x-0 top-1/2 -translate-y-1/2 text-center text-[9.5px] text-slate-300">drag, or tap +</span>}
|
||||
{windows[i].map(([s, e]) => (
|
||||
<div class="absolute inset-x-[3px] rounded-md border border-sky-400/60 bg-sky-500/15 overflow-hidden" style={`top:${y(s)}px; height:${y(e) - y(s)}px`}>
|
||||
<span class="absolute left-1.5 top-1 text-[9.5px] font-medium leading-tight text-sky-700 tabular-nums">{fmt(s)}<br />{fmt(e)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-3 text-[11px] text-slate-400">Each day is independent. Set different windows per day, or several in one day.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings > First email: the wait before a new contact's first email. -->
|
||||
<div class={`${FLOAT} left-[318px] top-[396px] w-[344px] overflow-hidden`}>
|
||||
<div class="px-3.5 pt-3 pb-2.5 flex items-center gap-2">
|
||||
<p class="text-[12.5px] font-medium text-slate-900">First email</p>
|
||||
<span class="ml-auto inline-flex h-7 items-center gap-1.5 rounded-md border border-slate-200 bg-white px-2 text-[12px] font-medium text-slate-700">After a wait<Glyph name="chevronDown" size={10} class="text-slate-400" /></span>
|
||||
<span class="inline-flex h-7 items-center rounded-md border border-sky-300 bg-sky-50 px-2.5 text-[11.5px] font-medium text-sky-700">2 days</span>
|
||||
</div>
|
||||
<div class="mx-3.5 mb-3.5 rounded-md border border-slate-200 bg-slate-50/60 px-3 py-3 flex items-start">
|
||||
<div class="w-[92px] shrink-0 flex flex-col items-center text-center">
|
||||
<span class="size-7 rounded-full bg-white text-slate-500 ring-1 ring-slate-200 inline-flex items-center justify-center"><Ico name="users" size={13} /></span>
|
||||
<span class="mt-1.5 text-[11px] font-medium text-slate-800">Contact joins</span>
|
||||
<span class="text-[10px] text-slate-400">Added to the campaign</span>
|
||||
</div>
|
||||
<div class="relative flex-1 mt-3.5 mx-1">
|
||||
<div class="border-t border-dashed border-sky-300"></div>
|
||||
<span class="absolute left-1/2 top-0 -translate-x-1/2 -translate-y-1/2 inline-flex items-center gap-1 h-5 px-1.5 rounded-full border border-sky-200 bg-sky-50 text-[10.5px] font-medium text-sky-700 whitespace-nowrap"><Ico name="timer" size={11} />waits 2 days</span>
|
||||
</div>
|
||||
<div class="w-[92px] shrink-0 flex flex-col items-center text-center">
|
||||
<span class="size-7 rounded-full bg-sky-600 text-white ring-1 ring-sky-600 inline-flex items-center justify-center"><Glyph name="mail" size={13} /></span>
|
||||
<span class="mt-1.5 text-[11px] font-medium text-slate-800">First email</span>
|
||||
<span class="text-[10px] text-slate-400">In the next sending window</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScaledStage>
|
||||
@@ -0,0 +1,136 @@
|
||||
---
|
||||
import ScaledStage from '../../ScaledStage.astro';
|
||||
import Glyph from '../Glyph.astro';
|
||||
import Ico from './Ico.astro';
|
||||
import { WINDOW, FLOAT, EYEBROW } from '../../../lib/scene';
|
||||
|
||||
/**
|
||||
* An email step's editor with an A/B test running (sequences/StepEmailArms,
|
||||
* StepSplitAllocator, the VariantEditor's stats row, EmailContentEditor and
|
||||
* ContentScore): the traffic-split bar, Variant B selected with its numbers
|
||||
* and the winner mark, its subject and body with merge fields.
|
||||
*/
|
||||
const arms = [
|
||||
{ name: 'Original', pct: 34, color: 'bg-sky-500' },
|
||||
{ name: 'Variant B', pct: 33, color: 'bg-violet-500', selected: true },
|
||||
{ name: 'Variant C', pct: 33, color: 'bg-emerald-500' },
|
||||
];
|
||||
const tools = ['bold', 'italic', 'underline', '|', 'list', 'link', 'image', '|'] as const;
|
||||
---
|
||||
<ScaledStage w={760} h={570} photo="sheer-lavender">
|
||||
<div class={`${WINDOW} left-[40px] top-[40px] w-[760px] h-[560px]`}>
|
||||
<div class="flex items-center justify-between border-b border-slate-200 px-4 py-2">
|
||||
<span class="text-[12.5px] font-medium text-slate-700">Edit “Quick question”</span>
|
||||
<span class="flex items-center gap-1 pr-10 text-slate-400">
|
||||
<span class="inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-[12px] text-slate-500"><Ico name="trash" size={14} />Delete</span>
|
||||
<span class="inline-flex size-7 items-center justify-center rounded-md"><Glyph name="x" size={16} /></span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="p-4 pr-14">
|
||||
<div class="rounded-md border border-slate-200 bg-white">
|
||||
<!-- StepSplitAllocator -->
|
||||
<div class="border-b border-slate-200 flex items-center gap-2 px-2 py-2">
|
||||
<div class="relative h-8 flex-1 min-w-0 rounded-md overflow-hidden ring-1 ring-inset ring-slate-200 flex">
|
||||
{arms.map((a) => (
|
||||
<span class={`${a.color} h-full flex items-center justify-center text-white ${a.selected ? 'ring-2 ring-inset ring-white/70' : ''}`} style={`width:${a.pct}%`}>
|
||||
<span class="px-1 truncate text-[11px] font-medium tabular-nums">{a.name} <span class="opacity-80">{a.pct}%</span></span>
|
||||
</span>
|
||||
))}
|
||||
<span class="absolute top-0 bottom-0 left-[34%] -ml-1.5 flex w-3 items-center justify-center"><span class="h-4 w-[3px] rounded-full bg-white/80 shadow ring-1 ring-slate-300"></span></span>
|
||||
<span class="absolute top-0 bottom-0 left-[67%] -ml-1.5 flex w-3 items-center justify-center"><span class="h-4 w-[3px] rounded-full bg-white/80 shadow ring-1 ring-slate-300"></span></span>
|
||||
</div>
|
||||
<span class="h-8 px-2 inline-flex items-center rounded-md text-[12px] font-medium text-slate-500">Even</span>
|
||||
<span class="h-8 px-2 inline-flex items-center gap-1 rounded-md bg-sky-600 text-[12px] font-medium text-white"><Glyph name="plus" size={13} />Variant</span>
|
||||
</div>
|
||||
|
||||
<!-- VariantEditor for B, which is winning -->
|
||||
<div class="space-y-3 p-3 bg-amber-50/30">
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class={`${EYEBROW} mb-1.5`}>Variant name</div>
|
||||
<div class="h-7 px-2.5 rounded-md border border-slate-200 bg-white text-[12.5px] text-slate-900 flex items-center">Variant B</div>
|
||||
</div>
|
||||
<span class="h-7 inline-flex items-center rounded-md px-2 text-[11px] font-medium tabular-nums bg-sky-50 text-sky-700">33% of contacts</span>
|
||||
<span class="size-7 inline-flex items-center justify-center rounded-md text-slate-400"><Glyph name="pause" size={13} /></span>
|
||||
<span class="size-7 inline-flex items-center justify-center rounded-md text-slate-400"><Ico name="trash" size={14} /></span>
|
||||
<span class="h-7 px-3 rounded-md bg-sky-600 text-[12px] font-medium text-white inline-flex items-center opacity-40">Save</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-x-4 rounded-md bg-slate-50 px-2.5 py-1.5 text-[11px] tabular-nums">
|
||||
<span class="se-win inline-flex items-center gap-1 font-medium text-amber-700"><Ico name="trophy" size={12} /> Winner</span>
|
||||
<span><span class="text-slate-400">Sent </span><span class="font-medium text-slate-700" data-tick="412,431,2800">412</span></span>
|
||||
<span><span class="text-slate-400">Open </span><span class="font-medium text-emerald-600">61.2%</span></span>
|
||||
<span><span class="text-slate-400">Reply </span><span class="font-medium text-sky-600">8.4%</span></span>
|
||||
<span><span class="text-slate-400">Bounce </span><span class="font-medium text-rose-600">0.7%</span></span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<span class={EYEBROW}>Subject</span>
|
||||
<span class="inline-flex items-center gap-0.5 text-slate-500"><Glyph name="braces" size={13} /><Glyph name="chevronDown" size={11} /></span>
|
||||
</div>
|
||||
<div class="h-7 px-2.5 rounded-md border border-slate-200 bg-white text-[12.5px] text-slate-900 flex items-center gap-1">Saw <span class="se-var"><Glyph name="braces" size={10} class="opacity-70" />Company</span> is hiring in Berlin</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<span class={EYEBROW}>Body</span>
|
||||
<span class="inline-flex items-center gap-0.5 rounded-md bg-slate-100 p-0.5 text-[11.5px] font-medium">
|
||||
<span class="h-6 px-2 inline-flex items-center gap-1 rounded bg-white text-slate-900 shadow-sm"><Ico name="pencilLine" size={12} />Edit</span>
|
||||
<span class="h-6 px-2 inline-flex items-center gap-1 rounded text-slate-500"><Ico name="eye" size={12} />Preview</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="rounded-md border border-slate-200 bg-white">
|
||||
<div class="flex items-center gap-0.5 border-b border-slate-200/70 px-1.5 py-1 text-slate-500">
|
||||
{tools.map((t) => t === '|'
|
||||
? <span class="mx-0.5 h-4 w-px bg-slate-200"></span>
|
||||
: <span class="size-7 inline-flex items-center justify-center rounded"><Ico name={t} size={14} /></span>
|
||||
)}
|
||||
<span class="h-7 px-1.5 inline-flex items-center gap-1 rounded"><Glyph name="braces" size={14} /><Glyph name="chevronDown" size={11} /></span>
|
||||
<span class="h-7 px-1.5 inline-flex items-center gap-1 rounded text-sky-600"><Glyph name="sparkles" size={14} /><span class="text-[11.5px] font-medium">AI</span></span>
|
||||
<span class="size-7 inline-flex items-center justify-center rounded"><Glyph name="split" size={14} /></span>
|
||||
<span class="size-7 inline-flex items-center justify-center rounded"><Ico name="shuffle" size={14} /></span>
|
||||
</div>
|
||||
<div class="se-body max-w-[408px] px-3 py-2.5 text-[13px] leading-relaxed text-slate-800">
|
||||
<p>Hi <span class="se-var"><Glyph name="braces" size={10} class="opacity-70" />First name</span>,</p>
|
||||
<p>Noticed <span class="se-var"><Glyph name="braces" size={10} class="opacity-70" />Company</span> posted six sales roles in Berlin this month. Teams that grow outbound that fast usually find their sender reputation is the first thing to crack.</p>
|
||||
<p>We keep it healthy while you scale. Worth 15 minutes next week to compare notes?</p>
|
||||
<p class="text-slate-500"><span class="se-var"><Glyph name="braces" size={10} class="opacity-70" />Sender name</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ContentScore, beside the body. -->
|
||||
<div class={`${FLOAT} left-[476px] top-[392px] w-[262px] overflow-hidden`}>
|
||||
<div class="px-3.5 py-2.5 border-b border-slate-200/60">
|
||||
<div class={EYEBROW}>Content check</div>
|
||||
<p class="mt-0.5 text-[11px] leading-relaxed text-slate-400">Advisory deliverability score. It never blocks sending.</p>
|
||||
</div>
|
||||
<div class="px-3.5 py-3">
|
||||
<div class="flex items-end gap-2">
|
||||
<span class="text-[22px] font-light leading-none tabular-nums text-emerald-600">84</span>
|
||||
<span class="text-[11px] text-slate-400">/ 100</span>
|
||||
<span class="ml-auto text-[11px] font-medium text-emerald-600">Looks good</span>
|
||||
</div>
|
||||
<div class="mt-2 h-1.5 rounded-full bg-slate-200 overflow-hidden"><div class="enter-grow-x h-full rounded-full bg-emerald-500" style="width:84%"></div></div>
|
||||
<p class="mt-2 inline-flex items-center gap-1.5 text-[11.5px] text-emerald-600"><Glyph name="shieldCheck" size={13} />No content issues found.</p>
|
||||
</div>
|
||||
</div>
|
||||
</ScaledStage>
|
||||
|
||||
<style>
|
||||
.se-var {
|
||||
display: inline-flex; align-items: center; gap: 0.2rem; height: 1.25rem; padding: 0 0.375rem;
|
||||
border-radius: 0.25rem; border: 1px solid #e2e8f0; background: #f8fafc; color: #475569;
|
||||
font-size: 11px; line-height: 1; font-weight: 500; white-space: nowrap; vertical-align: baseline;
|
||||
}
|
||||
.se-body p { margin: 0 0 0.55em; }
|
||||
.se-body p:last-child { margin-bottom: 0; }
|
||||
.se-win { animation: se-win 4s ease-in-out infinite; }
|
||||
@keyframes se-win { 0%, 100% { opacity: 1; } 50% { opacity: 0.55; } }
|
||||
</style>
|
||||
@@ -0,0 +1,144 @@
|
||||
---
|
||||
import ScaledStage from '../../ScaledStage.astro';
|
||||
import Glyph from '../Glyph.astro';
|
||||
import Ico from './Ico.astro';
|
||||
import { WINDOW, FLOAT, EYEBROW } from '../../../lib/scene';
|
||||
|
||||
/**
|
||||
* The contact's own inbox: the first email and the follow-up are one
|
||||
* conversation, because the follow-up replies on the thread. Her answer
|
||||
* lands at the bottom on a loop, and the lead's campaign panel (the contact
|
||||
* drawer's Activity tab) flips from waiting on step three to "Replied,
|
||||
* sending stopped".
|
||||
*/
|
||||
const steps = [
|
||||
{ name: 'Quick question', kind: 'Email 1', marks: ['Sent', 'Opened'] },
|
||||
{ name: 'Follow-up', kind: 'Email 2 · reply in thread', marks: ['Sent', 'Opened'] },
|
||||
{ name: 'Last note', kind: 'Email 3 · reply in thread', marks: [] },
|
||||
];
|
||||
---
|
||||
<ScaledStage w={760} h={570} photo="paint-aqua">
|
||||
<div class={`${WINDOW} left-[40px] top-[40px] w-[760px] h-[560px]`}>
|
||||
<!-- Mail client toolbar -->
|
||||
<div class="h-11 px-4 flex items-center gap-1 border-b border-slate-200 text-slate-500">
|
||||
<span class="size-8 inline-flex items-center justify-center rounded-full"><Ico name="arrowLeft" size={16} /></span>
|
||||
<span class="size-8 inline-flex items-center justify-center rounded-full"><Glyph name="archive" size={15} /></span>
|
||||
<span class="size-8 inline-flex items-center justify-center rounded-full"><Glyph name="alert" size={15} /></span>
|
||||
<span class="size-8 inline-flex items-center justify-center rounded-full"><Ico name="trash" size={15} /></span>
|
||||
<span class="mx-1 h-5 w-px bg-slate-200"></span>
|
||||
<span class="size-8 inline-flex items-center justify-center rounded-full"><Glyph name="mail" size={15} /></span>
|
||||
<span class="size-8 inline-flex items-center justify-center rounded-full"><Glyph name="clock" size={15} /></span>
|
||||
<span class="ml-auto pr-10 text-[12px] text-slate-400 tabular-nums">3 of 1,204</span>
|
||||
</div>
|
||||
|
||||
<div class="px-6 pt-5 pr-14">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<span class="text-[21px] text-slate-900">Quick question about Acme</span>
|
||||
<span class="inline-flex h-5 items-center gap-1 rounded bg-slate-100 px-1.5 text-[11.5px] text-slate-600">Inbox <Glyph name="x" size={10} class="text-slate-400" /></span>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 divide-y divide-slate-100 border-y border-slate-100">
|
||||
<!-- First email, collapsed -->
|
||||
<div class="py-3 flex items-center gap-3">
|
||||
<span class="size-9 shrink-0 rounded-full bg-sky-600 text-white text-[13px] font-medium flex items-center justify-center">A</span>
|
||||
<span class="w-[150px] shrink-0 text-[13px] font-semibold text-slate-900">Alex Kim</span>
|
||||
<span class="min-w-0 flex-1 truncate text-[13px] text-slate-500">Hi Jenna, saw Acme just opened a Berlin office. Teams growing that fast…</span>
|
||||
<span class="shrink-0 text-[12px] text-slate-500">Mon, Sep 7</span>
|
||||
</div>
|
||||
|
||||
<!-- The follow-up: a reply in the same conversation -->
|
||||
<div class="py-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="size-9 shrink-0 rounded-full bg-sky-600 text-white text-[13px] font-medium flex items-center justify-center">A</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-baseline gap-1.5">
|
||||
<span class="text-[13px] font-semibold text-slate-900">Alex Kim</span>
|
||||
<span class="text-[12px] text-slate-500"><alex@northwind.dev></span>
|
||||
</div>
|
||||
<div class="mt-0.5 inline-flex items-center gap-0.5 text-[12px] text-slate-500">to me <Glyph name="chevronDown" size={11} /></div>
|
||||
</div>
|
||||
<span class="shrink-0 text-[12px] text-slate-500">Thu, Sep 10, 9:40 AM</span>
|
||||
<span class="shrink-0 text-slate-400"><Glyph name="reply" size={15} /></span>
|
||||
</div>
|
||||
<div class="mt-3 max-w-[440px] pl-12 space-y-2.5 text-[13.5px] leading-[1.6] text-slate-800">
|
||||
<p>Hi Jenna,</p>
|
||||
<p>Following up in case this got buried. Happy to share how two teams your size kept outbound landing while they doubled their sending.</p>
|
||||
<p>Worth a quick call this week?</p>
|
||||
<p class="text-slate-500">Alex</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Her reply, arriving -->
|
||||
<div class="th-reply py-4 bg-white">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="size-9 shrink-0 rounded-full bg-emerald-600 text-white text-[13px] font-medium flex items-center justify-center">J</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-baseline gap-1.5">
|
||||
<span class="text-[13px] font-semibold text-slate-900">me</span>
|
||||
</div>
|
||||
<div class="mt-0.5 inline-flex items-center gap-0.5 text-[12px] text-slate-500">to Alex <Glyph name="chevronDown" size={11} /></div>
|
||||
</div>
|
||||
<span class="shrink-0 text-[12px] text-slate-500">11:03 AM</span>
|
||||
</div>
|
||||
<div class="mt-3 max-w-[440px] pl-12 text-[13.5px] leading-[1.6] text-slate-800">Thursday 10am ET works for me. Send an invite over?</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The lead's campaign panel in the contact drawer. -->
|
||||
<div class={`${FLOAT} left-[436px] top-[300px] w-[300px] overflow-hidden`}>
|
||||
<div class="px-3.5 py-2.5 border-b border-slate-200/70 flex items-center gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-[12.5px] font-medium text-slate-900">Series A founders, Q3</div>
|
||||
<div class="text-[10.5px] text-slate-400">via alex@northwind.dev</div>
|
||||
</div>
|
||||
<span class="relative inline-flex h-4 w-[86px] justify-end">
|
||||
<span class="th-a absolute right-0 inline-flex items-center gap-1 text-[10.5px] font-medium uppercase tracking-[0.08em] text-sky-700"><span class="campaign-grid text-sky-600"></span>Processing</span>
|
||||
<span class="th-b absolute right-0 inline-flex items-center gap-1 text-[10.5px] font-medium uppercase tracking-[0.08em] text-emerald-700"><Ico name="cornerUpLeft" size={12} />Replied</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="px-3.5 py-2.5">
|
||||
<div class={`${EYEBROW} mb-1.5`}>Flow</div>
|
||||
{steps.map((s, i) => (
|
||||
<div class={`relative flex items-center gap-2.5 py-1.5 ${i === 2 ? 'th-last' : ''}`}>
|
||||
<span class={`size-5 shrink-0 rounded-full flex items-center justify-center ${s.marks.length ? 'bg-sky-50 text-sky-600 ring-1 ring-sky-100' : 'bg-slate-50 text-slate-300 ring-1 ring-slate-200'}`}><Glyph name="mail" size={11} /></span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-[12px] text-slate-800">{s.name}</div>
|
||||
<div class="truncate text-[10.5px] text-slate-400">{s.kind}</div>
|
||||
</div>
|
||||
<span class="flex items-center gap-1">
|
||||
{s.marks.map((m) => <span class="inline-flex h-4 items-center rounded px-1 text-[10px] font-medium bg-slate-100 text-slate-600">{m}</span>)}
|
||||
{i === 1 && <span class="th-b inline-flex h-4 items-center rounded px-1 text-[10px] font-medium bg-emerald-50 text-emerald-700">Replied</span>}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div class="relative h-10 border-t border-slate-200/70 bg-slate-50/60 text-[11.5px]">
|
||||
<span class="th-a absolute inset-0 px-3.5 flex items-center gap-1.5 text-slate-600"><Glyph name="clock" size={12} class="text-slate-400" />Waiting 4 days after Follow-up</span>
|
||||
<span class="th-b absolute inset-0 px-3.5 flex items-center gap-1.5 font-medium text-emerald-700"><Glyph name="check" size={12} />Replied, sending stopped</span>
|
||||
</div>
|
||||
</div>
|
||||
</ScaledStage>
|
||||
|
||||
<style>
|
||||
/* One 12s loop: her reply lands, then the lead flips to Replied and the last
|
||||
step is dropped. Base styles are the end state, for reduced motion. */
|
||||
.th-a { opacity: 0; }
|
||||
.th-last { opacity: 0.45; text-decoration: line-through; text-decoration-color: #cbd5e1; }
|
||||
.th-reply { animation: th-reply 12s cubic-bezier(0.2, 0.8, 0.2, 1) infinite; }
|
||||
@keyframes th-reply {
|
||||
0%, 34% { opacity: 0; transform: translateY(10px); }
|
||||
40%, 94% { opacity: 1; transform: none; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
.th-a { animation: th-a 12s linear infinite; }
|
||||
.th-b { animation: th-b 12s linear infinite; }
|
||||
@keyframes th-a { 0%, 46% { opacity: 1; } 49%, 100% { opacity: 0; } }
|
||||
@keyframes th-b { 0%, 46% { opacity: 0; } 50%, 96% { opacity: 1; } 100% { opacity: 0; } }
|
||||
.th-last { animation: th-last 12s linear infinite; }
|
||||
@keyframes th-last {
|
||||
0%, 48% { opacity: 1; text-decoration-color: transparent; }
|
||||
54%, 100% { opacity: 0.45; text-decoration-color: #cbd5e1; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,148 @@
|
||||
---
|
||||
import ScaledStage from '../../ScaledStage.astro';
|
||||
import Glyph from '../Glyph.astro';
|
||||
import Ico from '../warmup/Ico.astro';
|
||||
import { WINDOW, EYEBROW, TOPBAR } from '../../../lib/scene';
|
||||
|
||||
/**
|
||||
* The Advisor on the Accounts page (AdvisorSummaryBar + AdvisorCard): one
|
||||
* summary line, expanded because something is critical, then a card per
|
||||
* finding with its fix. The finding with a one-click fix is open in the fix
|
||||
* drawer (AdvisorFixDrawer) on its "What changes" step. Titles, details and
|
||||
* remedies are the detectors' own wording (internal/app/advisor).
|
||||
*/
|
||||
type Sev = 'critical' | 'high' | 'medium' | 'low';
|
||||
const CHIP: Record<Sev, string> = {
|
||||
critical: 'bg-rose-500/10 text-rose-700 border-rose-500/20',
|
||||
high: 'bg-orange-500/10 text-orange-700 border-orange-500/20',
|
||||
medium: 'bg-amber-500/10 text-amber-700 border-amber-500/25',
|
||||
low: 'bg-sky-500/10 text-sky-700 border-sky-500/20',
|
||||
};
|
||||
const DOT: Record<Sev, string> = { critical: 'bg-rose-500', high: 'bg-orange-500', medium: 'bg-amber-500', low: 'bg-sky-500' };
|
||||
const LABEL: Record<Sev, string> = { critical: 'Critical', high: 'Needs attention', medium: 'Worth fixing', low: 'Suggestion' };
|
||||
const cards: { sev: Sev; title: string; detail: string; action: string; auto?: boolean }[] = [
|
||||
{ sev: 'critical', title: 'sales@brightco.io is missing DMARC', detail: 'The sending domain for sales@brightco.io has no valid DMARC record. Google\'s bulk sender rules require SPF, DKIM, and DMARC to be aligned.', action: 'How to fix' },
|
||||
{ sev: 'high', title: 'lea@northwind.io is landing in spam 14% of the time', detail: 'Over the last 7 days 7 of 50 warmup messages from lea@northwind.io delivered at Google, Microsoft and Yahoo went to spam.', action: 'Fix' },
|
||||
{ sev: 'medium', title: 'ops@northwind.co has no verified tracking domain', detail: 'ops@northwind.co is sending cold campaigns without its own verified tracking domain, so open pixels and click links go out on a shared host.', action: 'How to fix' },
|
||||
{ sev: 'low', title: 'growth@acme.co is capped above the safe band', detail: 'growth@acme.co is capped at 80 cold emails a day, above the 50/day safe band.', action: 'Fix', auto: true },
|
||||
];
|
||||
---
|
||||
<ScaledStage w={1240} h={640} photo="sheer-blue">
|
||||
<div class={`${WINDOW} left-[40px] top-[40px] w-[780px] h-[620px]`}>
|
||||
<div class={TOPBAR}>
|
||||
<span class={EYEBROW}>Accounts</span>
|
||||
<span class="h-4 w-px bg-slate-200"></span>
|
||||
<span class="text-[12.5px] text-slate-600">12 mailboxes</span>
|
||||
</div>
|
||||
<div class="px-5 py-3">
|
||||
<section class="overflow-hidden rounded-md border border-rose-500/20 bg-rose-500/[0.06]">
|
||||
<div class="flex items-center gap-2 px-2.5 py-2">
|
||||
<Glyph name="sparkles" size={14} class="text-slate-400" />
|
||||
<span class="min-w-0 flex-1 truncate text-[12.5px] text-slate-700"><span class="font-medium text-slate-900">4 mailboxes need urgent attention</span><span class="text-slate-500"> · each one is flagged on its row</span></span>
|
||||
<span class="flex items-center gap-1">
|
||||
{([['critical', 2], ['high', 1], ['medium', 1], ['low', 1]] as [Sev, number][]).map(([s, n]) => (
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-slate-500/[0.08] px-1.5 py-0.5 text-[10px] tabular-nums text-slate-600"><span class={`size-1.5 rounded-full ${DOT[s]}`}></span>{n}</span>
|
||||
))}
|
||||
</span>
|
||||
<span class="inline-flex h-6 items-center gap-1 rounded-md px-1.5 text-[11.5px] text-slate-500">Hide<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="m18 15-6-6-6 6" /></svg></span>
|
||||
</div>
|
||||
<div class="space-y-1.5 border-t border-slate-200/60 px-2.5 py-2.5">
|
||||
<!-- The critical one, open. -->
|
||||
<div class="ad-card rounded-md border border-slate-200/80 bg-white/70" style="--i:0">
|
||||
<div class="flex items-start gap-2 px-2.5 py-2">
|
||||
<span class="mt-[7px] size-1.5 shrink-0 rounded-full bg-rose-500"></span>
|
||||
<span class="min-w-0 flex-1 truncate text-[12.5px] font-medium text-slate-900">sales@brightco.io is bouncing 6.2% of its sends</span>
|
||||
<span class="inline-flex h-7 items-center rounded-md border border-slate-200 px-2 text-[12px] font-medium text-slate-600">How to fix</span>
|
||||
<span class="inline-flex size-7 items-center justify-center text-slate-400"><Glyph name="more" size={14} /></span>
|
||||
<span class="inline-flex size-7 items-center justify-center text-slate-400"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="m18 15-6-6-6 6" /></svg></span>
|
||||
</div>
|
||||
<div class="space-y-2.5 border-t border-slate-200/60 px-2.5 py-2.5">
|
||||
<p class="text-[12.5px] leading-relaxed text-slate-600">31 of 500 sends from sales@brightco.io bounced in the last 30 days. Amazon SES asks senders to stay under 5% and can pause sending at 10%; mailbox providers read a high bounce rate as a sender who does not know who they are mailing.</p>
|
||||
<div class="rounded-md border border-slate-200/70 bg-white/50 px-2.5 py-2">
|
||||
<p class={EYEBROW}>What to do</p>
|
||||
<p class="mt-1 text-[12.5px] leading-relaxed text-slate-700">This is a list problem, not a mailbox problem. Verify the contacts in the campaigns this mailbox sends for before sending more, and remove the domains that bounce repeatedly.</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class={EYEBROW}>What we measured</p>
|
||||
<div class="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{[['Bounce rate', '6.2%'], ['Sends, 30 days', '500'], ['Bounces, 30 days', '31']].map(([k, v]) => (
|
||||
<span class="inline-flex items-baseline gap-1.5 rounded-md border border-slate-200/70 bg-white/60 px-1.5 py-0.5"><span class="text-[11px] text-slate-500">{k}</span><span class="text-[11.5px] font-medium text-slate-800">{v}</span></span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class={`inline-flex items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-[0.08em] ${CHIP.critical}`}>Critical</span>
|
||||
<span class="inline-flex items-center rounded-md border border-slate-200/70 bg-slate-500/[0.06] px-1.5 py-0.5 text-[10px] uppercase tracking-[0.08em] text-slate-500">Deliverability</span>
|
||||
</div>
|
||||
<span class="flex items-center gap-0.5 text-slate-400"><span class="mr-1 text-[11px]">Useful?</span><span class="inline-flex size-6 items-center justify-center"><Ico name="thumbsUp" size={12} /></span><span class="inline-flex size-6 items-center justify-center"><Ico name="thumbsDown" size={12} /></span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{cards.map((c, i) => (
|
||||
<div class={`ad-card flex items-start gap-2 rounded-md border px-2.5 py-2 ${c.auto ? 'border-sky-300 bg-white ring-2 ring-sky-100' : 'border-slate-200/80 bg-white/70'}`} style={`--i:${i + 1}`}>
|
||||
<span class={`mt-[7px] size-1.5 shrink-0 rounded-full ${DOT[c.sev]}`}></span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="truncate text-[12.5px] font-medium text-slate-900">{c.title}</span>
|
||||
{c.auto && <span class="inline-flex shrink-0 items-center gap-1 rounded-md border border-sky-500/25 bg-sky-500/10 px-1.5 py-0.5 text-[10px] font-medium text-sky-700"><Ico name="zap" size={10} />Auto</span>}
|
||||
</div>
|
||||
<p class="mt-0.5 truncate text-[11.5px] text-slate-500">{c.detail}</p>
|
||||
</div>
|
||||
<span class={`inline-flex h-7 items-center rounded-md px-2 text-[12px] font-medium ${c.action === 'Fix' ? 'bg-sky-600 text-white' : 'border border-slate-200 text-slate-600'}`}>{c.action}</span>
|
||||
<span class="inline-flex size-7 items-center justify-center text-slate-400"><Glyph name="more" size={14} /></span>
|
||||
<span class="inline-flex size-7 items-center justify-center text-slate-400"><Glyph name="chevronDown" size={14} /></span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The cap finding, one step from applied. -->
|
||||
<div class="ad-drawer float-layer absolute left-[770px] top-[150px] w-[430px] overflow-hidden rounded-md border border-white/60 bg-white/95 shadow-[0_24px_60px_-20px_rgba(15,23,42,0.45)] ring-1 ring-slate-900/[0.06]">
|
||||
<div class="flex items-start gap-2 px-3 pt-2.5">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class={`inline-flex items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-[0.08em] ${CHIP.low}`}>{LABEL.low}</span>
|
||||
<span class="truncate text-[11px] text-slate-500">growth@acme.co</span>
|
||||
</div>
|
||||
<h2 class="mt-1 text-[13px] font-medium leading-snug text-slate-900">growth@acme.co is capped above the safe band</h2>
|
||||
</div>
|
||||
<span class="mt-0.5 inline-flex size-7 items-center justify-center text-slate-400"><Glyph name="x" size={14} /></span>
|
||||
</div>
|
||||
<div class="mt-2.5 flex items-center gap-1.5 border-b border-slate-200/70 px-3 pb-2">
|
||||
{['Why', 'What changes', 'Done'].map((s, i) => (
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="h-0.5 overflow-hidden rounded-full bg-slate-100"><div class={`h-full rounded-full ${i <= 1 ? 'bg-sky-500' : ''} ${i === 1 ? 'enter-grow-x' : ''}`} style="--d:500ms"></div></div>
|
||||
<span class={`mt-1 block text-[9.5px] uppercase tracking-[0.12em] ${i === 1 ? 'text-slate-600' : i === 0 ? 'text-slate-400' : 'text-slate-300'}`}>{s}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div class="space-y-3 px-3 py-3">
|
||||
<div class="rounded-md border border-slate-200/70 bg-white/50 px-2.5 py-2">
|
||||
<p class="text-[11px] text-slate-500">Daily cold cap</p>
|
||||
<div class="mt-1 flex items-center gap-2 text-[12.5px]">
|
||||
<span class="rounded border border-slate-200 bg-white px-1.5 py-0.5 text-slate-500 line-through decoration-slate-300">80/day</span>
|
||||
<Ico name="arrowRight" size={12} class="text-slate-400" />
|
||||
<span class="ad-to rounded border border-emerald-200 bg-emerald-50 px-1.5 py-0.5 font-medium text-emerald-700">50/day</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[12px] leading-relaxed text-slate-600">Bring the cap back to 50/day unless you have reviewed this mailbox's complaint and placement numbers specifically and decided to carry the risk.</p>
|
||||
<p class="flex items-center gap-1.5 text-[11px] text-slate-400"><Ico name="undo" size={12} />Undo stays available after it is applied.</p>
|
||||
</div>
|
||||
<div class="flex items-center justify-between border-t border-slate-200/70 px-3 py-2.5">
|
||||
<span class="inline-flex h-7 items-center gap-1 px-2 text-[12.5px] text-slate-500"><Ico name="arrowLeft" size={12} />Back</span>
|
||||
<span class="inline-flex h-7 items-center gap-1.5 rounded-md bg-sky-600 px-2.5 text-[12.5px] font-medium text-white"><Glyph name="check" size={14} />Set the cap to 50/day</span>
|
||||
</div>
|
||||
</div>
|
||||
</ScaledStage>
|
||||
|
||||
<style>
|
||||
/* Findings settle in one after another when the scene first plays. */
|
||||
.js-stage:not(.is-seen) .ad-card { opacity: 0; transform: translateY(-4px); }
|
||||
.ad-card { transition: opacity 400ms ease calc(var(--i) * 90ms), transform 400ms ease calc(var(--i) * 90ms); }
|
||||
.ad-to { animation: ad-to 5s ease-in-out infinite; }
|
||||
@keyframes ad-to { 0%, 100% { box-shadow: 0 0 0 0 rgb(16 185 129 / 0); } 50% { box-shadow: 0 0 0 4px rgb(16 185 129 / 0.15); } }
|
||||
@media (prefers-reduced-motion: reduce) { .ad-card { transition: none; } }
|
||||
</style>
|
||||
@@ -0,0 +1,141 @@
|
||||
---
|
||||
import ScaledStage from '../../ScaledStage.astro';
|
||||
import Glyph from '../Glyph.astro';
|
||||
import Ico from '../warmup/Ico.astro';
|
||||
import { WINDOW, FLOAT, EYEBROW } from '../../../lib/scene';
|
||||
|
||||
/**
|
||||
* Sending domains (web/src/app/app/emails/domains/page.tsx): authentication,
|
||||
* tracking host and website redirect for every domain the workspace sends
|
||||
* from. A failing domain is open in the drawer, with the DMARC record the
|
||||
* Advisor hands over to paste at the registrar.
|
||||
*/
|
||||
type Tone = 'emerald' | 'amber' | 'rose' | 'slate';
|
||||
const PILL: Record<Tone, { pill: string; dot: string }> = {
|
||||
emerald: { pill: 'bg-emerald-50 text-emerald-700 ring-emerald-200/70', dot: 'bg-emerald-500' },
|
||||
amber: { pill: 'bg-amber-50 text-amber-700 ring-amber-200/70', dot: 'bg-amber-500' },
|
||||
rose: { pill: 'bg-rose-50 text-rose-700 ring-rose-200/70', dot: 'bg-rose-500' },
|
||||
slate: { pill: 'bg-slate-50 text-slate-500 ring-slate-200', dot: 'bg-slate-300' },
|
||||
};
|
||||
type Pill = [Tone, string];
|
||||
const domains: { d: string; mark: 'google' | 'microsoft' | 'imap'; n: number; auth: Pill; track: Pill; redirect: Pill; open?: boolean }[] = [
|
||||
{ d: 'northwind.dev', mark: 'google', n: 5, auth: ['emerald', 'Authenticated'], track: ['emerald', 'link.northwind.dev'], redirect: ['emerald', 'northwind.com'] },
|
||||
{ d: 'northwind.io', mark: 'microsoft', n: 3, auth: ['emerald', 'Authenticated'], track: ['emerald', 'link.northwind.io'], redirect: ['emerald', 'northwind.com'] },
|
||||
{ d: 'brightco.io', mark: 'google', n: 2, auth: ['rose', 'Auth failing'], track: ['amber', 'Waiting for DNS'], redirect: ['slate', 'None'], open: true },
|
||||
{ d: 'helio.gen', mark: 'microsoft', n: 2, auth: ['emerald', 'Authenticated'], track: ['emerald', 'link.helio.gen'], redirect: ['emerald', 'helio.com'] },
|
||||
{ d: 'acme.co', mark: 'google', n: 4, auth: ['emerald', 'Authenticated'], track: ['amber', '2 of 4 mailboxes'], redirect: ['emerald', 'acme.com'] },
|
||||
{ d: 'northwind.co', mark: 'imap', n: 1, auth: ['slate', 'Not checked'], track: ['slate', 'Shared host'], redirect: ['slate', 'None'] },
|
||||
];
|
||||
const stats = [
|
||||
{ label: 'Domains', value: '6', sub: 'sending mail' },
|
||||
{ label: 'Mailboxes', value: '17', sub: 'across 6 domains' },
|
||||
{ label: 'Authenticated', value: '4', of: 6, tone: 'bg-emerald-500' },
|
||||
{ label: 'Tracking verified', value: '3', of: 6, tone: 'bg-sky-500' },
|
||||
];
|
||||
const grid = 'grid grid-cols-[14px_minmax(0,1.5fr)_64px_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_14px] items-center gap-x-3';
|
||||
const auth = [
|
||||
{ k: 'SPF', state: 'Found', on: true, what: 'Lists the servers allowed to send for the domain.' },
|
||||
{ k: 'DKIM', state: 'Not confirmed', on: false, what: 'Signs each message so it cannot be altered on the way.' },
|
||||
{ k: 'DMARC', state: 'Not found', on: false, missing: true, what: 'Tells receivers what to do with mail that fails the other two.' },
|
||||
];
|
||||
---
|
||||
<ScaledStage w={760} h={570} photo="sheer-cream">
|
||||
<div class={`${WINDOW} left-[30px] top-[30px] w-[740px] h-[560px]`}>
|
||||
<div class="h-12 px-5 border-b border-slate-200 flex items-center gap-3">
|
||||
<span class="inline-flex items-center gap-1 h-6 -ml-1.5 px-1.5 rounded-md text-[11.5px] text-slate-500"><Ico name="arrowLeft" size={12} />Accounts</span>
|
||||
<span class="h-4 w-px bg-slate-200"></span>
|
||||
<span class={EYEBROW}>Sending domains</span>
|
||||
<span class="truncate text-[12.5px] text-slate-600">Authentication, tracking and redirects</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 gap-2.5 border-b border-slate-200 px-5 py-4">
|
||||
{stats.map((s) => (
|
||||
<div class="rounded-lg border border-slate-200 bg-white px-3 py-2.5">
|
||||
<div class={`${EYEBROW} truncate`}>{s.label}</div>
|
||||
<div class="mt-1 flex items-baseline gap-1">
|
||||
<span class="text-[22px] font-light leading-none tabular-nums text-slate-900">{s.value}</span>
|
||||
{s.of && <span class="text-[11px] text-slate-400 tabular-nums">of {s.of}</span>}
|
||||
</div>
|
||||
{s.of
|
||||
? <div class="mt-2 h-1 overflow-hidden rounded-full bg-slate-100"><div class={`enter-grow-x h-full rounded-full ${s.tone}`} style={`width:${(Number(s.value) / s.of) * 100}%; --d:300ms`}></div></div>
|
||||
: <div class="mt-1.5 truncate text-[10.5px] text-slate-400">{s.sub}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div class="flex items-center gap-1 border-b border-slate-200 px-3">
|
||||
{[['All', '6', true], ['Needs attention', '3', false], ['Vendor managed', '0', false]].map(([l, n, a]) => (
|
||||
<span class={`relative inline-flex h-10 items-center gap-1.5 px-2.5 text-[12.5px] ${a ? 'font-medium text-slate-900' : 'text-slate-500'}`}>
|
||||
{l}
|
||||
<span class={`inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full px-1 text-[10.5px] tabular-nums ${a ? 'bg-sky-50 text-sky-700' : 'bg-slate-100 text-slate-500'}`}>{n}</span>
|
||||
{a && <span class="absolute bottom-0 left-1.5 right-1.5 h-0.5 rounded-full bg-sky-600"></span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div class={`${grid} h-8 border-b border-slate-200/60 bg-slate-50/40 px-5`}>
|
||||
<span class="block size-3.5 rounded-[4px] border border-slate-300 bg-white"></span>
|
||||
{['Domain', 'Mailboxes', 'Authentication', 'Tracking', 'Redirect'].map((h, i) => <span class={`text-[10px] font-medium uppercase tracking-[0.14em] text-slate-400 ${i === 1 ? 'text-right' : ''}`}>{h}</span>)}
|
||||
<span></span>
|
||||
</div>
|
||||
{domains.map((d) => (
|
||||
<div class={`${grid} border-b border-slate-200/60 px-5 py-2.5 ${d.open ? 'bg-sky-50/50' : ''}`}>
|
||||
<span class="block size-3.5 rounded-[4px] border border-slate-300 bg-white"></span>
|
||||
<div class="flex min-w-0 items-center gap-2.5">
|
||||
<span class="inline-flex size-6 shrink-0 items-center justify-center rounded-md border border-slate-200 bg-white"><Glyph mark={d.mark} size={13} /></span>
|
||||
<span class="truncate text-[12.5px] font-medium text-slate-900">{d.d}</span>
|
||||
</div>
|
||||
<span class="text-right text-[12px] tabular-nums text-slate-700">{d.n}</span>
|
||||
{[d.auth, d.track, d.redirect].map(([t, label]) => (
|
||||
<span class="flex min-w-0">
|
||||
<span class={`inline-flex h-5 min-w-0 max-w-full items-center gap-1.5 rounded-full px-2 text-[10.5px] font-medium whitespace-nowrap ring-1 ring-inset ${PILL[t].pill}`}><span class={`size-1.5 shrink-0 rounded-full ${PILL[t].dot}`}></span><span class="truncate">{label}</span></span>
|
||||
</span>
|
||||
))}
|
||||
<Ico name="chevronRight" size={14} class="text-slate-300" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<!-- brightco.io in the drawer, and the record that fixes it. -->
|
||||
<div class={`${FLOAT} left-[440px] top-[196px] w-[302px] overflow-hidden`}>
|
||||
<div class="flex items-center gap-2.5 border-b border-slate-200 px-4 py-3">
|
||||
<span class="inline-flex size-7 items-center justify-center rounded-lg bg-sky-50 text-sky-600"><Glyph name="globe" size={14} /></span>
|
||||
<div class="min-w-0">
|
||||
<div class="text-[12.5px] font-medium text-slate-900">brightco.io</div>
|
||||
<div class="text-[10.5px] text-slate-400">2 mailboxes · Google Workspace</div>
|
||||
</div>
|
||||
<span class="ml-auto inline-flex h-5 items-center gap-1.5 rounded-full bg-rose-50 px-2 text-[10.5px] font-medium text-rose-700 ring-1 ring-inset ring-rose-200/70"><span class="size-1.5 rounded-full bg-rose-500"></span>Auth failing</span>
|
||||
</div>
|
||||
<div class="px-4 py-3">
|
||||
<div class={EYEBROW}>Authentication</div>
|
||||
<div class="mt-2 divide-y divide-slate-100 rounded-md border border-slate-200">
|
||||
{auth.map((a) => (
|
||||
<div class="flex items-start gap-2.5 px-3 py-2">
|
||||
<span class={`mt-0.5 inline-flex size-4 shrink-0 items-center justify-center rounded-full ${a.on ? 'bg-emerald-50 text-emerald-600' : a.missing ? 'bg-rose-50 text-rose-600' : 'bg-slate-100 text-slate-400'}`}>
|
||||
{a.on ? <Glyph name="check" size={10} /> : <Ico name="minus" size={10} />}
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2"><span class="text-[12.5px] font-medium text-slate-900">{a.k}</span><span class={`text-[11px] ${a.missing ? 'text-rose-600' : 'text-slate-500'}`}>{a.state}</span></div>
|
||||
<p class="text-[11px] leading-snug text-slate-500">{a.what}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div class={`${EYEBROW} mt-3`}>Paste this</div>
|
||||
<div class="mt-1.5 space-y-1">
|
||||
{[['DMARC record type', 'TXT'], ['DMARC host', '_dmarc.brightco.io'], ['DMARC value', 'v=DMARC1; p=none; rua=mailto:dmarc@brightco.io']].map(([k, v], i) => (
|
||||
<div class="dm-row flex items-center gap-2 rounded-md border border-slate-200/70 bg-white/50 px-2.5 py-1.5" style={`--i:${i}`}>
|
||||
<span class="w-[92px] shrink-0 truncate text-[10px] uppercase tracking-[0.08em] text-slate-400">{k}</span>
|
||||
<code class="min-w-0 flex-1 truncate font-mono text-[11px] text-slate-800">{v}</code>
|
||||
<Ico name="copy" size={12} class="text-slate-400" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScaledStage>
|
||||
|
||||
<style>
|
||||
.dm-row { animation: dm-row 9s ease-out infinite; animation-delay: calc(var(--i) * 0.25s); animation-fill-mode: both; }
|
||||
@keyframes dm-row {
|
||||
0%, 6% { opacity: 0; transform: translateY(4px); }
|
||||
12%, 100% { opacity: 1; transform: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
import ScaledStage from '../../ScaledStage.astro';
|
||||
import Glyph from '../Glyph.astro';
|
||||
import Ico from '../warmup/Ico.astro';
|
||||
import { WINDOW, FLOAT, EYEBROW, SECTIONBAR, STAT, STAT_VALUE, STAT_SUB } from '../../../lib/scene';
|
||||
|
||||
/**
|
||||
* Deliverability > Warmup inbox placement (WarmupPlacementSection.tsx): the
|
||||
* workspace's numbers, then every mailbox ranked worst first with its daily
|
||||
* rate, spam count and band. A mailbox on watch is open in a small card.
|
||||
*/
|
||||
type Band = 'good' | 'fair' | 'poor';
|
||||
const BAND: Record<Band, { label: string; chip: string; dot: string; text: string; stroke: string }> = {
|
||||
good: { label: 'Healthy', chip: 'bg-emerald-50 text-emerald-700 border-emerald-200', dot: 'bg-emerald-500', text: 'text-emerald-600', stroke: '#10b981' },
|
||||
fair: { label: 'Watch', chip: 'bg-amber-50 text-amber-700 border-amber-200', dot: 'bg-amber-500', text: 'text-amber-600', stroke: '#f59e0b' },
|
||||
poor: { label: 'Landing in spam', chip: 'bg-rose-50 text-rose-700 border-rose-200', dot: 'bg-rose-500', text: 'text-rose-600', stroke: '#f43f5e' },
|
||||
};
|
||||
const rows: { email: string; rate: number; band: Band; obs: number; spam: number; spark: number[] }[] = [
|
||||
{ email: 'dev@sunriselabs.io', rate: 77, band: 'poor', obs: 48, spam: 11, spark: [94, 92, 90, 88, 85, 83, 80, 79, 77] },
|
||||
{ email: 'lea@northwind.io', rate: 86, band: 'fair', obs: 50, spam: 7, spark: [96, 95, 94, 92, 90, 89, 88, 87, 86] },
|
||||
{ email: 'ml@helio.gen', rate: 88.5, band: 'fair', obs: 61, spam: 7, spark: [84, 85, 85, 86, 87, 87, 88, 88, 88.5] },
|
||||
{ email: 'ops@northwind.co', rate: 92, band: 'good', obs: 74, spam: 6, spark: [90, 91, 90, 92, 91, 92, 93, 92, 92] },
|
||||
{ email: 'hello@helio.gen', rate: 94, band: 'good', obs: 88, spam: 5, spark: [93, 94, 93, 94, 95, 94, 94, 93, 94] },
|
||||
{ email: 'sam@northwind.io', rate: 95, band: 'good', obs: 92, spam: 5, spark: [95, 94, 96, 95, 95, 96, 95, 95, 95] },
|
||||
{ email: 'sales@brightco.io', rate: 96, band: 'good', obs: 101, spam: 4, spark: [97, 96, 96, 95, 96, 97, 96, 96, 96] },
|
||||
{ email: 'alex@northwind.dev', rate: 97, band: 'good', obs: 104, spam: 3, spark: [96, 97, 97, 98, 97, 97, 98, 97, 97] },
|
||||
];
|
||||
const sparkPath = (v: number[]) => v.map((y, i) => `${i ? 'L' : 'M'} ${(i / (v.length - 1)) * 64} ${18 - ((y - 70) / 30) * 16}`).join(' ');
|
||||
---
|
||||
<ScaledStage w={760} h={570} photo="lake-motion">
|
||||
<div class={`${WINDOW} left-[36px] top-[36px] w-[724px] h-[540px]`}>
|
||||
<div class={SECTIONBAR}>
|
||||
<span class={EYEBROW}>Warmup inbox placement</span>
|
||||
<span class="ml-auto inline-flex rounded-md bg-slate-100 p-0.5 text-[11px] font-medium">
|
||||
<span class="inline-flex h-6 items-center rounded bg-white px-2 text-slate-900 shadow-sm">All</span>
|
||||
<span class="inline-flex h-6 items-center px-2 text-slate-500">Google</span>
|
||||
<span class="inline-flex h-6 items-center px-2 text-slate-500">Microsoft</span>
|
||||
<span class="inline-flex h-6 items-center px-2 text-slate-500">Yahoo</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 border-b border-slate-200">
|
||||
<div class={STAT}><div class={EYEBROW}>Non-spam share · 7 days</div><div class={`${STAT_VALUE} text-emerald-600`}>93.6%</div><div class={STAT_SUB}>Healthy</div></div>
|
||||
<div class={STAT}><div class={EYEBROW}>Classified receipts</div><div class={STAT_VALUE}><span data-tick="1204,1240,2600">1204</span></div><div class={STAT_SUB}>of 1,262 submissions</div></div>
|
||||
<div class="px-5 py-4"><div class={EYEBROW}>Spam</div><div class={`${STAT_VALUE} text-rose-600`}>77</div><div class={STAT_SUB}>6.4% · at the big three only</div></div>
|
||||
</div>
|
||||
<div class={SECTIONBAR}>
|
||||
<span class={EYEBROW}>Mailboxes by inbox rate</span>
|
||||
<span class="font-mono text-[10.5px] text-slate-400 tabular-nums">12</span>
|
||||
<span class="ml-auto inline-flex h-6 items-center rounded border border-amber-200 bg-amber-50 px-2 text-[11px] font-medium text-amber-700">Below 90% only · 3</span>
|
||||
</div>
|
||||
{rows.map((m, i) => (
|
||||
<div class="flex h-11 items-center gap-3 border-b border-slate-200/60 px-5">
|
||||
<span class={`size-1.5 shrink-0 rounded-full ${BAND[m.band].dot}`}></span>
|
||||
<span class="min-w-0 flex-1 truncate text-[12.5px] font-medium text-slate-900">{m.email}</span>
|
||||
<svg width="64" height="20" viewBox="0 0 64 20" class="overflow-visible"><path class="enter-draw" d={sparkPath(m.spark)} fill="none" stroke={BAND[m.band].stroke} stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" pathLength="1" stroke-dasharray="1" style={`--d:${300 + i * 80}ms`} /></svg>
|
||||
<span class="w-16 text-right font-mono text-[11px] tabular-nums text-slate-500">{m.obs} obs</span>
|
||||
<span class={`w-14 text-right font-mono text-[11px] tabular-nums ${m.spam ? 'text-rose-600' : 'text-slate-400'}`}>{m.spam} spam</span>
|
||||
<span class={`w-12 text-right font-mono text-[11.5px] font-medium tabular-nums ${BAND[m.band].text}`}>{m.rate}%</span>
|
||||
<span class={`inline-flex h-5 w-[118px] shrink-0 items-center justify-center gap-1 rounded border px-1.5 text-[10px] font-medium ${BAND[m.band].chip}`}><span class={`size-1.5 rounded-full ${BAND[m.band].dot}`}></span>{BAND[m.band].label}</span>
|
||||
<Ico name="chevronRight" size={14} class="text-slate-300" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<!-- What the worst mailbox's band means for it right now. -->
|
||||
<div class={`${FLOAT} left-[22px] top-[396px] w-[318px] p-4`}>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-flex size-7 items-center justify-center rounded-lg bg-amber-50 text-amber-600"><Glyph name="gauge" size={14} /></span>
|
||||
<div class="min-w-0">
|
||||
<div class="text-[12.5px] font-medium text-slate-900">dev@sunriselabs.io is throttled</div>
|
||||
<div class="text-[10.5px] text-slate-400">23% spam at Google, Microsoft and Yahoo</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 grid grid-cols-2 gap-2 text-[11px]">
|
||||
<div class="rounded-md bg-slate-50 px-2.5 py-2"><div class="text-slate-400">Warmup</div><div class="mt-0.5 font-medium text-slate-900">Half volume</div></div>
|
||||
<div class="rounded-md bg-slate-50 px-2.5 py-2"><div class="text-slate-400">Cold sending</div><div class="mt-0.5 font-medium text-slate-900">Half volume</div></div>
|
||||
</div>
|
||||
<div class="mt-2.5 text-[11px] text-slate-500">Lifts on its own below 15%.</div>
|
||||
</div>
|
||||
</ScaledStage>
|
||||
@@ -0,0 +1,221 @@
|
||||
---
|
||||
import HeroStage from '../warmup/HeroStage.astro';
|
||||
import AppShellMock from '../../AppShellMock.astro';
|
||||
import Glyph from '../Glyph.astro';
|
||||
import Ico from '../warmup/Ico.astro';
|
||||
import { FLOAT, EYEBROW, SECTIONBAR } from '../../../lib/scene';
|
||||
|
||||
/**
|
||||
* A finished placement test (web/src/app/app/placement/[id]/page.tsx) inside
|
||||
* the app shell: the folder breakdown, the result per provider family and
|
||||
* every seed's verdict, which arrive one by one the first time the scene is
|
||||
* seen. The notification the test sends when it finishes floats over the nav.
|
||||
*/
|
||||
type Folder = 'inbox' | 'promotions' | 'spam' | 'missing';
|
||||
const FOLDER: Record<Folder, { label: string; chip: string; dot: string }> = {
|
||||
inbox: { label: 'Inbox', chip: 'bg-emerald-50 text-emerald-700 border-emerald-200', dot: 'bg-emerald-500' },
|
||||
promotions: { label: 'Promotions', chip: 'bg-violet-50 text-violet-700 border-violet-200', dot: 'bg-violet-500' },
|
||||
spam: { label: 'Spam', chip: 'bg-rose-50 text-rose-700 border-rose-200', dot: 'bg-rose-500' },
|
||||
missing: { label: 'Not observed by timeout', chip: 'bg-slate-100 text-slate-700 border-slate-200', dot: 'bg-slate-500' },
|
||||
};
|
||||
// 20 copies: 16 inbox, 2 Promotions, 1 spam, 1 never arrived.
|
||||
const families = [
|
||||
{ name: 'Gmail', inbox: 3, tabs: 1, spam: 0, missing: 0 },
|
||||
{ name: 'Google Workspace', inbox: 3, tabs: 1, spam: 0, missing: 0 },
|
||||
{ name: 'Outlook.com', inbox: 2, tabs: 0, spam: 1, missing: 0 },
|
||||
{ name: 'Microsoft 365', inbox: 3, tabs: 0, spam: 0, missing: 0 },
|
||||
{ name: 'Yahoo Mail', inbox: 2, tabs: 0, spam: 0, missing: 1 },
|
||||
{ name: 'iCloud', inbox: 3, tabs: 0, spam: 0, missing: 0 },
|
||||
];
|
||||
const seeds: { seed: string; family: string; seen: string; folder: Folder }[] = [
|
||||
{ seed: 'a***@gmail.com', family: 'Gmail', seen: 'seen Oct 8, 2:16 PM', folder: 'inbox' },
|
||||
{ seed: 'm***@outlook.com', family: 'Outlook.com', seen: 'seen Oct 8, 2:17 PM', folder: 'inbox' },
|
||||
{ seed: 'r***@lumenhq.com', family: 'Google Workspace', seen: 'seen Oct 8, 2:18 PM', folder: 'inbox' },
|
||||
{ seed: 't***@yahoo.com', family: 'Yahoo Mail', seen: 'seen Oct 8, 2:19 PM', folder: 'inbox' },
|
||||
{ seed: 'k***@contoso-group.com', family: 'Microsoft 365', seen: 'seen Oct 8, 2:20 PM', folder: 'inbox' },
|
||||
{ seed: 'e***@icloud.com', family: 'iCloud', seen: 'seen Oct 8, 2:22 PM', folder: 'inbox' },
|
||||
{ seed: 's***@gmail.com', family: 'Gmail', seen: 'seen Oct 8, 2:23 PM', folder: 'promotions' },
|
||||
{ seed: 'd***@hotmail.com', family: 'Outlook.com', seen: 'seen Oct 8, 2:24 PM', folder: 'spam' },
|
||||
{ seed: 'p***@fieldworks.io', family: 'Google Workspace', seen: 'seen Oct 8, 2:25 PM', folder: 'inbox' },
|
||||
{ seed: 'l***@yahoo.com', family: 'Yahoo Mail', seen: 'sent Oct 8, 2:26 PM', folder: 'missing' },
|
||||
{ seed: 'b***@northpeak.co', family: 'Microsoft 365', seen: 'seen Oct 8, 2:28 PM', folder: 'inbox' },
|
||||
{ seed: 'n***@me.com', family: 'iCloud', seen: 'seen Oct 8, 2:29 PM', folder: 'inbox' },
|
||||
];
|
||||
const cells = [
|
||||
{ label: 'Inbox', rate: '80%', tone: 'text-emerald-600', dot: 'bg-emerald-500', sub: '16 copies' },
|
||||
{ label: 'Gmail tabs', rate: '10%', tone: 'text-violet-600', dot: 'bg-violet-500', sub: '2 copies, 2 Promotions, 0 other tabs' },
|
||||
{ label: 'Spam', rate: '5%', tone: 'text-rose-600', dot: 'bg-rose-500', sub: '1 copy' },
|
||||
{ label: 'Not observed', rate: '5%', tone: 'text-slate-600', dot: 'bg-slate-500', sub: '1 copy, Not seen within 2 hours' },
|
||||
];
|
||||
const pct = (n: number, d: number) => `${Math.round((n / d) * 100)}%`;
|
||||
const rateTone = (r: number) => (r >= 0.8 ? 'text-emerald-600' : r >= 0.6 ? 'text-amber-600' : 'text-rose-600');
|
||||
const bar = (f: { inbox: number; tabs: number; spam: number; missing: number }) => {
|
||||
const t = f.inbox + f.tabs + f.spam + f.missing;
|
||||
return [
|
||||
{ w: f.inbox / t, c: 'ph-em' },
|
||||
{ w: f.tabs / t, c: 'ph-vi' },
|
||||
{ w: f.spam / t, c: 'ph-ro' },
|
||||
{ w: f.missing / t, c: 'ph-sl' },
|
||||
].filter((s) => s.w > 0);
|
||||
};
|
||||
---
|
||||
<HeroStage w={1240} h={780} photo="paint-blue">
|
||||
<div class="absolute left-[48px] top-[48px] w-[1192px] h-[732px] overflow-hidden rounded-tl-[12px] ring-1 ring-slate-900/[0.08] shadow-[0_50px_100px_-40px_rgba(15,23,42,0.55)]">
|
||||
<div class="w-[1386px] origin-top-left" style="transform:scale(0.86)">
|
||||
<AppShellMock crumb="Placement tests" sub="Quick question about Brightline">
|
||||
<div class="flex flex-col bg-white">
|
||||
<div class="px-5 pt-4">
|
||||
<span class="inline-flex items-center gap-1 h-6 -ml-1.5 px-1.5 mb-1 rounded-md text-[11.5px] text-slate-500"><Ico name="arrowLeft" size={12} />Placement tests</span>
|
||||
</div>
|
||||
<div class="px-5 pb-4 flex items-start gap-3 border-b border-slate-200">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="text-[18px] font-semibold text-slate-900">Quick question about Brightline</div>
|
||||
<span class="inline-flex items-center gap-1.5 h-5 px-1.5 rounded-md border text-[10.5px] font-medium bg-emerald-50 text-emerald-700 border-emerald-200"><span class="size-1.5 rounded-full bg-emerald-500"></span>Completed</span>
|
||||
<span class="inline-flex items-center gap-1 h-5 px-1.5 rounded-md bg-slate-100 text-[10.5px] font-medium text-slate-600"><Ico name="eyeOff" size={12} />Untracked</span>
|
||||
</div>
|
||||
<div class="mt-1.5 flex items-center gap-x-3 text-[11.5px] text-slate-500">
|
||||
<span>From <span class="text-slate-800">alex@northwind.dev</span></span>
|
||||
<span>Shared panel</span>
|
||||
<span>Started Oct 8, 2:14 PM</span>
|
||||
<span>Finished Oct 8, 2:41 PM</span>
|
||||
<span class="inline-flex items-center gap-0.5 text-sky-700">Q4 outbound<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M7 7h10v10" /><path d="M7 17 17 7" /></svg></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Folder breakdown -->
|
||||
<div class="border-b border-slate-200">
|
||||
<div class="grid grid-cols-4">
|
||||
{cells.map((c, i) => (
|
||||
<div class={`px-5 py-4 ${i < 3 ? 'border-r border-slate-200' : ''}`}>
|
||||
<div class="flex items-center gap-2"><span class={`size-1.5 rounded-full ${c.dot}`}></span><span class={EYEBROW}>{c.label}</span></div>
|
||||
<div class={`mt-2 text-[26px] font-light leading-none tabular-nums ${c.tone}`}>{c.rate}</div>
|
||||
<div class="mt-1.5 truncate font-mono text-[10.5px] text-slate-400">{c.sub}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div class="px-5 pb-4 pt-1">
|
||||
<div class="flex h-2 overflow-hidden">
|
||||
<div class="enter-grow-x ph-em" style="width:80%; --d:200ms"></div>
|
||||
<div class="ph-vi" style="width:10%"></div>
|
||||
<div class="ph-ro" style="width:5%"></div>
|
||||
<div class="ph-sl" style="width:5%"></div>
|
||||
</div>
|
||||
<p class="mt-2 text-[11px] text-slate-500">20 of 20 copies were sent and classified. Rates are shares of the copies that were sent.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[1.12fr_1fr]">
|
||||
<!-- By provider, then the content check -->
|
||||
<section class="min-w-0 border-r border-slate-200">
|
||||
<div class={SECTIONBAR}>
|
||||
<span class={EYEBROW}>By provider</span>
|
||||
<span class="font-mono text-[10.5px] text-slate-400 tabular-nums">6</span>
|
||||
<span class="ml-auto flex items-center gap-3 text-[11px] text-slate-500">
|
||||
{(['inbox', 'promotions', 'spam', 'missing'] as Folder[]).map((f) => (
|
||||
<span class="inline-flex items-center gap-1.5"><span class={`size-1.5 rounded-full ${FOLDER[f].dot}`}></span>{f === 'missing' ? 'Not observed' : FOLDER[f].label}</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
<table class="w-full text-left">
|
||||
<thead>
|
||||
<tr class="h-8 border-b border-slate-200/60 text-[10px] uppercase tracking-[0.14em] text-slate-400">
|
||||
<th class="px-5 font-medium">Provider</th>
|
||||
<th class="px-3 font-medium w-[30%]"></th>
|
||||
<th class="px-3 font-medium text-right">Inbox</th>
|
||||
<th class="px-3 font-medium text-right">Tabs</th>
|
||||
<th class="px-3 font-medium text-right">Spam</th>
|
||||
<th class="px-3 font-medium text-right">Missing</th>
|
||||
<th class="px-5 font-medium text-right">Copies</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-200/60">
|
||||
{families.map((f, i) => {
|
||||
const t = f.inbox + f.tabs + f.spam + f.missing;
|
||||
return (
|
||||
<tr class="h-10">
|
||||
<td class="px-5 text-[12.5px] font-medium text-slate-900 whitespace-nowrap">{f.name}</td>
|
||||
<td class="px-3">
|
||||
<div class="flex h-1.5 overflow-hidden">
|
||||
{bar(f).map((s, j) => <div class={`${j === 0 ? 'enter-grow-x ' : ''}${s.c}`} style={`width:${s.w * 100}%; --d:${300 + i * 80}ms`}></div>)}
|
||||
</div>
|
||||
</td>
|
||||
<td class={`px-3 text-right font-mono text-[11.5px] tabular-nums ${rateTone(f.inbox / t)}`}>{pct(f.inbox, t)}</td>
|
||||
<td class="px-3 text-right font-mono text-[11.5px] tabular-nums text-violet-600">{pct(f.tabs, t)}</td>
|
||||
<td class="px-3 text-right font-mono text-[11.5px] tabular-nums text-rose-600">{pct(f.spam, t)}</td>
|
||||
<td class="px-3 text-right font-mono text-[11.5px] tabular-nums text-slate-500">{pct(f.missing, t)}</td>
|
||||
<td class="px-5 text-right font-mono text-[11px] tabular-nums text-slate-400">{t}/{t}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class={`${SECTIONBAR} border-t border-slate-200`}><span class={EYEBROW}>Content check</span></div>
|
||||
<div class="px-5 py-3">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-[22px] font-light tabular-nums text-emerald-600">88</span>
|
||||
<span class="text-[11px] text-slate-400">out of 100</span>
|
||||
<span class="text-[11.5px] font-medium text-emerald-600">Looks good</span>
|
||||
</div>
|
||||
<p class="mt-1 text-[11.5px] text-slate-500">Nothing in the copy stands out to a spam filter.</p>
|
||||
<p class="mt-2 text-[11px] leading-relaxed text-slate-400">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>
|
||||
</section>
|
||||
|
||||
<!-- Every seed's verdict, arriving one by one -->
|
||||
<section class="min-w-0">
|
||||
<div class={SECTIONBAR}>
|
||||
<span class={EYEBROW}>Seed inboxes</span>
|
||||
<span class="font-mono text-[10.5px] text-slate-400 tabular-nums">20</span>
|
||||
</div>
|
||||
<p class="px-5 pt-3 text-[11px] text-slate-400">Addresses on a shared panel are partly hidden.</p>
|
||||
<ul class="divide-y divide-slate-200/60">
|
||||
{seeds.map((s, i) => (
|
||||
<li class="enter-fade px-5 py-2 flex min-h-11 items-center gap-3" style={`--d:${500 + i * 140}ms`}>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate font-mono text-[12.5px] text-slate-800">{s.seed}</div>
|
||||
<div class="truncate text-[11px] text-slate-400">{s.family}, {s.seen}</div>
|
||||
</div>
|
||||
<span class={`inline-flex items-center gap-1.5 h-5 px-1.5 rounded-md border text-[10.5px] font-medium whitespace-nowrap ${FOLDER[s.folder].chip}`}>
|
||||
<span class={`size-1.5 rounded-full ${FOLDER[s.folder].dot}`}></span>{FOLDER[s.folder].label}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</AppShellMock>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The notification the finished test sends. -->
|
||||
<div class={`${FLOAT} left-[22px] top-[440px] w-[246px] overflow-hidden`}>
|
||||
<div class="flex items-center gap-2 border-b border-slate-200 px-3.5 py-2.5">
|
||||
<span class="inline-flex size-6 items-center justify-center rounded-md bg-sky-50 text-sky-600"><Ico name="bell" size={13} /></span>
|
||||
<span class="text-[12px] font-medium text-slate-900">Placement test finished</span>
|
||||
<span class="ml-auto text-[10.5px] text-slate-400">now</span>
|
||||
</div>
|
||||
<div class="px-3.5 py-3">
|
||||
<div class="truncate text-[11.5px] text-slate-500">Quick question about Brightline</div>
|
||||
<div class="mt-2 flex items-baseline gap-2">
|
||||
<span class="text-[34px] font-light leading-none tracking-[-0.02em] text-emerald-600 tabular-nums">80%</span>
|
||||
<span class="text-[11.5px] text-slate-500">reached the inbox</span>
|
||||
</div>
|
||||
<div class="mt-3 flex h-1.5 overflow-hidden rounded-full">
|
||||
<div class="ph-em" style="width:80%"></div><div class="ph-vi" style="width:10%"></div><div class="ph-ro" style="width:5%"></div><div class="ph-sl" style="width:5%"></div>
|
||||
</div>
|
||||
<p class="mt-2.5 text-[11.5px] leading-relaxed text-slate-600">16 of 20 copies reached the inbox, 2 a Gmail tab, 1 spam and 1 never arrived.</p>
|
||||
</div>
|
||||
</div>
|
||||
</HeroStage>
|
||||
|
||||
<style>
|
||||
/* The app's dithered share bars: dots over a lighter wash of the same tone. */
|
||||
.ph-em, .ph-vi, .ph-ro, .ph-sl { height: 100%; background-size: 2px 2px; }
|
||||
.ph-em { background-color: rgb(16 185 129 / 0.6); background-image: radial-gradient(circle, rgb(16 185 129) 0.8px, transparent 1px); }
|
||||
.ph-vi { background-color: rgb(139 92 246 / 0.6); background-image: radial-gradient(circle, rgb(139 92 246) 0.8px, transparent 1px); }
|
||||
.ph-ro { background-color: rgb(244 63 94 / 0.6); background-image: radial-gradient(circle, rgb(244 63 94) 0.8px, transparent 1px); }
|
||||
.ph-sl { background-color: rgb(100 116 139 / 0.5); background-image: radial-gradient(circle, rgb(100 116 139) 0.8px, transparent 1px); }
|
||||
</style>
|
||||
@@ -0,0 +1,124 @@
|
||||
---
|
||||
import ScaledStage from '../../ScaledStage.astro';
|
||||
import Glyph from '../Glyph.astro';
|
||||
import Ico from '../warmup/Ico.astro';
|
||||
import { WINDOW, FLOAT, EYEBROW, TOPBAR, SECTIONBAR, ACTION_GHOST, ACTION_PRIMARY } from '../../../lib/scene';
|
||||
|
||||
/**
|
||||
* The Accounts page with each mailbox's sends today against its own cap,
|
||||
* counting up as campaigns run. Over it, one mailbox's Sending tab
|
||||
* (SendingBehaviorTab.tsx): the workday it rolled for today.
|
||||
*/
|
||||
type Row = { addr: string; name: string; provider: string; mark: 'google' | 'microsoft' | 'imap'; sent: number; cap: number; inbox: number; health: number; resting?: boolean; tick?: number; selected?: boolean };
|
||||
const rows: Row[] = [
|
||||
{ addr: 'alex@northwind.dev', name: 'Alex Kim', provider: 'Google Workspace', mark: 'google', sent: 31, cap: 50, inbox: 97, health: 96, tick: 2900 },
|
||||
{ addr: 'sam@northwind.io', name: 'Sam Ortiz', provider: 'Microsoft 365', mark: 'microsoft', sent: 18, cap: 50, inbox: 95, health: 93, selected: true },
|
||||
{ addr: 'ops@northwind.co', name: 'Ops team', provider: 'SMTP / IMAP', mark: 'imap', sent: 22, cap: 50, inbox: 92, health: 90, tick: 3600 },
|
||||
{ addr: 'hello@helio.gen', name: 'Lena Fischer', provider: 'Microsoft 365', mark: 'microsoft', sent: 27, cap: 50, inbox: 94, health: 94, tick: 4100 },
|
||||
{ addr: 'growth@acme.co', name: 'Maya Okonkwo', provider: 'Google Workspace', mark: 'google', sent: 12, cap: 30, inbox: 97, health: 96, tick: 5200 },
|
||||
{ addr: 'sales@brightco.io', name: 'Ana Silva', provider: 'Google Workspace', mark: 'google', sent: 34, cap: 50, inbox: 96, health: 95 },
|
||||
{ addr: 'leo@northwind.dev', name: 'Leo Park', provider: 'Google Workspace', mark: 'google', sent: 29, cap: 50, inbox: 98, health: 98, tick: 3300 },
|
||||
{ addr: 'partners@acme.co', name: 'Ravi Shah', provider: 'Google Workspace', mark: 'google', sent: 0, cap: 50, inbox: 95, health: 94, resting: true },
|
||||
];
|
||||
const th = 'px-3 py-2 text-[10px] font-medium text-slate-400 uppercase tracking-[0.14em] text-left';
|
||||
---
|
||||
<ScaledStage w={760} h={570} photo="reeded-glass">
|
||||
<div class={`${WINDOW} left-[30px] top-[30px] w-[740px] h-[560px]`}>
|
||||
<div class={TOPBAR}>
|
||||
<span class={EYEBROW}>Accounts</span>
|
||||
<span class="h-4 w-px bg-slate-200"></span>
|
||||
<span class="text-[12.5px] text-slate-600">8 mailboxes</span>
|
||||
<div class="ml-auto flex items-center gap-1.5 pr-8">
|
||||
<span class={ACTION_GHOST}><Glyph name="globe" size={12} />Sending domains</span>
|
||||
<span class={ACTION_PRIMARY}><Glyph name="plus" size={12} />Add account</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class={SECTIONBAR}>
|
||||
<span class={EYEBROW}>Mailboxes</span>
|
||||
<span class="font-mono text-[10.5px] text-slate-400 tabular-nums">8</span>
|
||||
<div class="ml-auto mr-8 h-7 w-52 pl-2 rounded-md border border-slate-200 bg-white flex items-center gap-1.5"><Glyph name="search" size={13} class="text-slate-400" /><span class="text-[12.5px] text-slate-400">Search by email…</span></div>
|
||||
</div>
|
||||
<table class="w-full table-fixed">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-200">
|
||||
<th class="pl-5 pr-2 py-2 w-11"><span class="block size-3.5 rounded-[4px] border border-slate-300"></span></th>
|
||||
<th class={th}>Mailbox</th>
|
||||
<th class={`${th} w-[100px]`}>Status</th>
|
||||
<th class={`${th} w-[150px]`}>Sent today</th>
|
||||
<th class={`${th} w-[72px]`}>Inbox</th>
|
||||
<th class={`${th} w-[126px]`}>Health</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr class={`h-11 border-b border-slate-200/60 ${r.selected ? 'bg-sky-50/70' : ''}`}>
|
||||
<td class="pl-5 pr-2"><span class="block size-3.5 rounded-[4px] border border-slate-300"></span></td>
|
||||
<td class="px-3">
|
||||
<div class="flex items-center gap-2.5 min-w-0">
|
||||
<span class="inline-flex size-6 shrink-0 items-center justify-center rounded-full border border-slate-200 bg-white"><Glyph mark={r.mark} size={r.mark === 'imap' ? 13 : 14} /></span>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-[12.5px] font-medium leading-tight text-slate-900">{r.addr}</div>
|
||||
<div class="mt-0.5 truncate text-[11px] leading-tight text-slate-400"><span class="text-slate-500">{r.name}</span> · {r.provider}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3">
|
||||
{r.resting ? (
|
||||
<span class="inline-flex items-center gap-1.5 text-[10.5px] font-medium uppercase tracking-[0.08em] text-violet-600"><Glyph name="moon" size={12} class="text-violet-500" />Resting</span>
|
||||
) : (
|
||||
<span class="inline-flex items-center gap-1.5 text-[10.5px] font-medium uppercase tracking-[0.08em] text-sky-700"><Glyph name="send" size={12} class="text-sky-600" /><span class="text-shimmer">Sending</span></span>
|
||||
)}
|
||||
</td>
|
||||
<td class="px-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class={`inline-flex w-[62px] items-center gap-1.5 font-mono text-[12px] tabular-nums ${r.sent ? 'text-sky-700' : 'text-slate-500'}`}>
|
||||
<Glyph name="send" size={12} class="text-sky-500" />
|
||||
<span>{r.tick ? <span data-tick={`${r.sent},${r.sent + 4},${r.tick}`}>{r.sent}</span> : r.sent}<span class="text-slate-400">/{r.cap}</span></span>
|
||||
</span>
|
||||
<span class="h-1 flex-1 overflow-hidden rounded-full bg-slate-100"><span class="enter-grow-x block h-full rounded-full bg-sky-400" style={`width:${(r.sent / r.cap) * 100}%; --d:300ms`}></span></span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3"><span class="inline-flex items-center gap-1.5 font-mono text-[11.5px] font-medium tabular-nums text-emerald-600"><span class="size-1.5 rounded-full bg-emerald-500"></span>{r.inbox}%</span></td>
|
||||
<td class="px-3"><span class="inline-flex items-center gap-1.5 text-[11px] font-medium text-emerald-700"><span class="size-1.5 rounded-full bg-emerald-500"></span><span class="uppercase tracking-[0.08em]">Healthy {r.health}</span></span></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- sam@northwind.io's Sending tab: the workday it rolled for today. -->
|
||||
<div class={`${FLOAT} left-[372px] top-[302px] w-[364px] overflow-hidden`}>
|
||||
<div class="flex items-center gap-2 border-b border-slate-200 px-4 py-2.5">
|
||||
<Glyph mark="microsoft" size={13} />
|
||||
<span class="text-[12.5px] font-medium text-slate-900">sam@northwind.io</span>
|
||||
<span class="ml-auto inline-flex items-center gap-1 text-[11.5px] text-slate-500"><Glyph name="clock" size={12} />Sending</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 border-b border-slate-200/60 px-4 py-3">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<span class="inline-flex size-8 items-center justify-center rounded-lg bg-sky-50 text-sky-600"><Glyph name="clock" size={16} /></span>
|
||||
<div>
|
||||
<div class="text-[12.5px] font-medium text-slate-900">Variable sending plan</div>
|
||||
<div class="text-[11px] text-slate-400">Hours, volume and spacing re-rolled each day</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="relative inline-block h-[18px] w-8 rounded-full bg-sky-600"><span class="absolute right-0.5 top-0.5 size-[14px] rounded-full bg-white"></span></span>
|
||||
</div>
|
||||
<div class="px-4 py-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class={EYEBROW}>Today's workday</span>
|
||||
<span class="font-mono text-[10.5px] text-slate-400">Europe/Berlin</span>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-x-3.5 gap-y-1.5 text-[12px] text-slate-700">
|
||||
<span class="inline-flex items-center gap-1.5"><Glyph name="clock" size={13} class="text-slate-400" /><b class="tabular-nums text-slate-900">09:14</b><span class="text-slate-400">–</span><b class="tabular-nums text-slate-900">17:41</b></span>
|
||||
<span class="inline-flex items-center gap-1.5"><Ico name="coffee" size={13} class="text-slate-400" /><span class="tabular-nums">12:38–13:16</span></span>
|
||||
<span class="inline-flex items-center gap-1.5"><Ico name="timer" size={13} class="text-slate-400" />90s–7m apart</span>
|
||||
<span class="inline-flex items-center gap-1.5"><Glyph name="gauge" size={13} class="text-slate-400" />max 7/hour</span>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center justify-between text-[12px] text-slate-600">
|
||||
<span><b class="tabular-nums text-slate-900">18</b> of <b class="tabular-nums text-slate-900">31</b> sent</span>
|
||||
<span class="tabular-nums text-slate-400">13 left in plan</span>
|
||||
</div>
|
||||
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-slate-100"><div class="enter-grow-x h-full w-[58%] rounded-full bg-sky-500" style="--d:700ms"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</ScaledStage>
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
import ScaledStage from '../../ScaledStage.astro';
|
||||
import Glyph from '../Glyph.astro';
|
||||
import Ico from '../warmup/Ico.astro';
|
||||
import { WINDOW, FLOAT, EYEBROW, TOPBAR, SECTIONBAR, ACTION_PRIMARY, TAB } from '../../../lib/scene';
|
||||
|
||||
/**
|
||||
* Contacts > Suppression list (web/src/app/app/contacts/suppressions/page.tsx).
|
||||
* A reply asking to stop lands in the inbox and, a moment later, its sender
|
||||
* arrives at the top of the list, on a loop.
|
||||
*/
|
||||
type Entry = { who: string; domain?: boolean; reason: string; source: string; mine: boolean; when: string };
|
||||
const incoming: Entry = { who: 'karim@stratech.io', reason: 'Replied asking to stop', source: 'Unsubscribed', mine: true, when: 'Oct 9, 2:52 PM' };
|
||||
const entries: Entry[] = [
|
||||
{ who: 'r.patel@quarry.co', reason: 'Hard bounce: the mailbox does not exist', source: 'Bounced', mine: true, when: 'Oct 9, 11:04 AM' },
|
||||
{ who: 'acme.co', domain: true, reason: 'Existing customer', source: 'Added by hand', mine: false, when: 'Oct 8, 4:30 PM' },
|
||||
{ who: 'm.lopez@brightline.io', reason: 'Complaint report from the recipient’s provider', source: 'Spam complaint', mine: true, when: 'Oct 8, 9:12 AM' },
|
||||
{ who: 'jonas@helio.gen', reason: 'One-click unsubscribe from the mail client', source: 'Unsubscribed', mine: true, when: 'Oct 7, 6:41 PM' },
|
||||
{ who: 'legal@northwind.com', reason: 'Our own legal team', source: 'Added by hand', mine: false, when: 'Oct 2, 10:05 AM' },
|
||||
{ who: 'ops@verdant.co', reason: 'Do-not-contact list from the CRM', source: 'Imported', mine: false, when: 'Sep 29, 3:18 PM' },
|
||||
];
|
||||
const tabs = [
|
||||
{ label: 'All contacts', icon: 'users' },
|
||||
{ label: 'Segments', icon: 'layers' },
|
||||
{ label: 'Labels', icon: 'tag' },
|
||||
{ label: 'Suppression list', icon: 'ban', active: true },
|
||||
] as const;
|
||||
---
|
||||
<ScaledStage w={760} h={570} photo="paint-aqua">
|
||||
<div class={`${WINDOW} left-[30px] top-[30px] w-[740px] h-[560px]`}>
|
||||
<div class="flex items-center gap-1 border-b border-slate-200 px-3">
|
||||
{tabs.map((t) => (
|
||||
<span class={`${TAB} ${'active' in t ? 'font-medium text-slate-900' : 'text-slate-500'}`}>
|
||||
{t.icon === 'users' || t.icon === 'ban' ? <Ico name={t.icon} size={14} /> : <Glyph name={t.icon} size={14} />}{t.label}
|
||||
{'active' in t && <span class="absolute bottom-0 left-1.5 right-1.5 h-0.5 rounded-full bg-sky-600"></span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div class={TOPBAR}>
|
||||
<span class={EYEBROW}>Suppression list</span>
|
||||
<span class="h-4 w-px bg-slate-200"></span>
|
||||
<span class="text-[12.5px] text-slate-600">Addresses and domains no campaign will email</span>
|
||||
<span class={`${ACTION_PRIMARY} ml-auto`}><Glyph name="plus" size={12} />Add</span>
|
||||
</div>
|
||||
<div class={SECTIONBAR}>
|
||||
<span class={EYEBROW}>Entries</span>
|
||||
<span class="font-mono text-[10.5px] text-slate-400 tabular-nums">214</span>
|
||||
<div class="ml-auto h-7 w-64 pl-2 rounded-md border border-slate-200 bg-white flex items-center gap-1.5"><Glyph name="search" size={13} class="text-slate-400" /><span class="text-[12.5px] text-slate-400">Search addresses or domains…</span></div>
|
||||
</div>
|
||||
<div class="relative h-[400px] overflow-hidden">
|
||||
<div class="sp-list">
|
||||
{[incoming, ...entries].map((e, i) => (
|
||||
<div class={`flex h-11 items-center gap-3 border-b border-slate-200/60 px-5 ${i === 0 ? 'sp-new' : ''}`}>
|
||||
{e.domain ? <Glyph name="globe" size={14} class="text-slate-400" /> : <Glyph name="mail" size={14} class="text-slate-400" />}
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-[12.5px] font-medium text-slate-900">{e.domain ? `@${e.who}` : e.who}</div>
|
||||
<div class="truncate text-[11px] text-slate-500">{e.reason}</div>
|
||||
</div>
|
||||
<span class={`inline-flex h-5 shrink-0 items-center rounded border px-1.5 text-[10px] font-medium ${e.mine ? 'border-red-200 bg-red-50 text-red-700' : 'border-slate-200 bg-slate-100 text-slate-600'}`}>{e.source}</span>
|
||||
<span class="w-[104px] shrink-0 text-right text-[11px] tabular-nums text-slate-400">{e.when}</span>
|
||||
<span class="inline-flex size-7 items-center justify-center text-slate-400"><Glyph name="more" size={14} /></span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The reply that put karim@stratech.io on the list. -->
|
||||
<div class={`${FLOAT} sp-reply left-[390px] top-[388px] w-[350px] overflow-hidden`}>
|
||||
<div class="flex items-center gap-2 border-b border-slate-200 px-4 py-2.5">
|
||||
<Glyph name="inbox" size={13} class="text-slate-400" />
|
||||
<span class="text-[11.5px] text-slate-500">Reply to sam@northwind.io</span>
|
||||
<span class="ml-auto inline-flex h-4 items-center gap-1 rounded-sm px-1.5 text-[10px] font-medium" style="color:#64748b;background-color:#64748b14;"><span class="size-1.5 rounded-full bg-slate-500"></span>Unsubscribe</span>
|
||||
</div>
|
||||
<div class="px-4 py-3">
|
||||
<div class="flex items-baseline gap-2"><span class="text-[12.5px] font-semibold text-slate-900">Karim Salah</span><span class="text-[11px] text-slate-400">Re: Quick question about Stratech</span></div>
|
||||
<p class="mt-1.5 text-[12.5px] leading-[1.55] text-slate-700">Please take me off your list.</p>
|
||||
<div class="mt-3 flex items-center gap-1.5 text-[11px] font-medium text-red-700"><Ico name="ban" size={12} />Suppressed in every campaign</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScaledStage>
|
||||
|
||||
<style>
|
||||
/* An 11s loop: the reply shows up, then its sender is added on top
|
||||
(rows are 44px). Base styles are the end state. */
|
||||
.sp-list { animation: sp-list 11s cubic-bezier(0.2, 0.8, 0.2, 1) infinite; }
|
||||
@keyframes sp-list {
|
||||
0%, 30% { transform: translateY(-44px); }
|
||||
38%, 94% { transform: none; opacity: 1; }
|
||||
99%, 100% { transform: none; opacity: 0; }
|
||||
}
|
||||
.sp-new { animation: sp-new 11s ease-out infinite; }
|
||||
@keyframes sp-new { 0%, 34% { background: rgb(254 242 242 / 0); } 40%, 76% { background: rgb(254 242 242); } 92%, 100% { background: rgb(254 242 242 / 0); } }
|
||||
.sp-reply { animation: sp-reply 11s ease-out infinite; }
|
||||
@keyframes sp-reply {
|
||||
0%, 4% { opacity: 0; translate: 0 8px; }
|
||||
10%, 92% { opacity: 1; translate: 0 0; }
|
||||
98%, 100% { opacity: 0; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,134 @@
|
||||
---
|
||||
import ScaledStage from '../../ScaledStage.astro';
|
||||
import Glyph from '../Glyph.astro';
|
||||
import Ico from '../campaigns/Ico.astro';
|
||||
import { WINDOW, FLOAT, EYEBROW } from '../../../lib/scene';
|
||||
|
||||
/**
|
||||
* Settings > Sending > Send-time optimization (settings/sending/page.tsx):
|
||||
* the switch, contact timezones, the fallback, weekends and the delivery
|
||||
* hours grid. Over it, one contact's day: the campaign window, the mailbox
|
||||
* workday and her own hours, and the stretch where all three agree.
|
||||
*/
|
||||
const nav = [
|
||||
{ group: 'Account', items: ['Profile', 'Notifications', 'Security'] },
|
||||
{ group: 'Workspace', items: ['Members', 'Teams', 'Workspace', 'Sending', 'Website tracking', 'Inbox tagging'] },
|
||||
];
|
||||
const rows = [
|
||||
{ label: 'Use the recipient’s local hours', desc: 'On. Sends aim for the hours below.' },
|
||||
{ label: 'Read each contact’s timezone', desc: 'Uses the contact’s timezone field, then the country its email domain points at.' },
|
||||
];
|
||||
const picked = [9, 10, 11, 14, 15, 16];
|
||||
const fmtHour = (h: number) => (h === 0 ? '12am' : h === 12 ? '12pm' : h < 12 ? `${h}am` : `${h - 12}pm`);
|
||||
const toggle = '<span class="relative block h-4 w-7 rounded-full bg-slate-900"><span class="absolute top-0.5 left-0.5 size-3 translate-x-3 rounded-full bg-white"></span></span>';
|
||||
|
||||
// One contact's day on a London axis, 06:00 to 22:00. She is in New York, so
|
||||
// her 9am to noon is 14:00 to 17:00 here and her afternoon hours fall at 19:00.
|
||||
const A0 = 6 * 60, A1 = 22 * 60, AW = 270;
|
||||
const ax = (m: number) => ((m - A0) / (A1 - A0)) * AW;
|
||||
const span = (a: number, b: number) => `left:${ax(a)}px; width:${ax(b) - ax(a)}px`;
|
||||
const lanes = [
|
||||
{ k: 'Campaign window', sub: '08:00 to 18:00', parts: [[8 * 60, 18 * 60]], c: 'bg-slate-300' },
|
||||
{ k: 'Mailbox workday', sub: '09:14 to 17:41', parts: [[9 * 60 + 14, 12 * 60 + 32], [13 * 60 + 19, 17 * 60 + 41]], c: 'bg-sky-300' },
|
||||
{ k: 'Dana’s hours', sub: 'America/New_York', parts: [[14 * 60, 17 * 60], [19 * 60, 22 * 60]], c: 'bg-violet-300' },
|
||||
];
|
||||
---
|
||||
<ScaledStage w={760} h={570} photo="sheer-sky">
|
||||
<div class={`${WINDOW} left-[40px] top-[40px] w-[740px] h-[560px] flex`}>
|
||||
<div class="w-[176px] shrink-0 border-r border-slate-200/70 px-2.5 py-3">
|
||||
{nav.map((g, gi) => (
|
||||
<div class={gi ? 'mt-3' : ''}>
|
||||
<div class={`px-2 mb-1 ${EYEBROW}`}>{g.group}</div>
|
||||
{g.items.map((it) => (
|
||||
<div class={`h-8 px-2.5 rounded-md flex items-center gap-2.5 text-[12.5px] ${it === 'Sending' ? 'bg-slate-200/70 font-medium text-slate-900' : 'text-slate-600'}`}>
|
||||
<Glyph name={it === 'Sending' ? 'send' : it === 'Security' ? 'shieldCheck' : it === 'Notifications' ? 'info' : it === 'Website tracking' ? 'globe' : it === 'Inbox tagging' ? 'tag' : 'user'} size={14} class={it === 'Sending' ? 'text-slate-900' : 'text-slate-400'} />{it}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="px-8 pt-6 pb-4 border-b border-slate-200/70">
|
||||
<h2 class="text-[16px] font-semibold tracking-tight text-slate-900">Sending</h2>
|
||||
<p class="mt-0.5 text-[12.5px] leading-relaxed text-slate-500">When campaign mail goes out, measured in your recipient’s day.</p>
|
||||
</div>
|
||||
<section class="px-8 py-5">
|
||||
<header class="mb-3">
|
||||
<h3 class="text-[12.5px] font-semibold tracking-tight text-slate-900">Send-time optimization</h3>
|
||||
<p class="mt-1 text-[11.5px] leading-relaxed text-slate-500">Hold each campaign email until it lands inside the hours you pick, in the recipient’s own timezone. It only ever delays a send.</p>
|
||||
</header>
|
||||
<div class="space-y-3">
|
||||
{rows.map((r) => (
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[12.5px] font-medium leading-tight text-slate-900">{r.label}</div>
|
||||
<div class="mt-0.5 text-[11.5px] leading-tight text-slate-500">{r.desc}</div>
|
||||
</div>
|
||||
<Fragment set:html={toggle} />
|
||||
</div>
|
||||
))}
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[12.5px] font-medium leading-tight text-slate-900">Fallback timezone</div>
|
||||
<div class="mt-0.5 text-[11.5px] leading-tight text-slate-500">Used when a contact’s timezone cannot be worked out.</div>
|
||||
</div>
|
||||
<span class="h-7 px-2 rounded-md border border-slate-200 bg-white inline-flex items-center gap-1.5 text-[12px] font-medium text-slate-700">America/New_York<Glyph name="chevronDown" size={10} class="text-slate-400" /></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[12.5px] font-medium leading-tight text-slate-900">Skip weekends</div>
|
||||
<div class="mt-0.5 text-[11.5px] leading-tight text-slate-500">Push a send that would land on Saturday or Sunday to the next weekday.</div>
|
||||
</div>
|
||||
<Fragment set:html={toggle} />
|
||||
</div>
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="min-w-0 flex-1 text-[12.5px] font-medium leading-tight text-slate-900">Delivery hours</div>
|
||||
<div class="w-[300px]">
|
||||
<div class="grid grid-cols-6 gap-1">
|
||||
{Array.from({ length: 24 }, (_, h) => (
|
||||
<span class={`h-7 rounded-md border text-[11.5px] flex items-center justify-center ${picked.includes(h) ? 'bg-sky-50 text-sky-700 border-sky-200' : 'bg-white text-slate-500 border-slate-200'}`}>{fmtHour(h)}</span>
|
||||
))}
|
||||
</div>
|
||||
<p class="mt-2 inline-flex items-start gap-1.5 text-[11.5px] leading-relaxed text-slate-500"><Glyph name="clock" size={13} class="mt-px text-slate-400" />Mail arrives around 9am-11am, 2pm-4pm for each recipient.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- One contact's day: the three calendars and where they overlap. -->
|
||||
<div class={`${FLOAT} left-[16px] top-[336px] w-[402px] overflow-hidden`}>
|
||||
<div class="px-4 py-2.5 border-b border-slate-200/70 flex items-center gap-2">
|
||||
<span class="size-6 shrink-0 rounded-full bg-violet-100 text-violet-700 text-[9.5px] font-semibold flex items-center justify-center">DW</span>
|
||||
<span class="min-w-0 flex-1 truncate text-[12px] text-slate-900">dana@orbit.app <span class="text-slate-400">· America/New_York</span></span>
|
||||
<span class="font-mono text-[10px] text-slate-400">London time</span>
|
||||
</div>
|
||||
<div class="px-4 pt-3 pb-2 space-y-2">
|
||||
{lanes.map((l) => (
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-[96px] shrink-0">
|
||||
<div class="text-[11.5px] font-medium leading-tight text-slate-800">{l.k}</div>
|
||||
<div class="text-[10px] leading-tight text-slate-400">{l.sub}</div>
|
||||
</div>
|
||||
<div class="relative h-5 rounded bg-slate-50 ring-1 ring-inset ring-slate-200" style={`width:${AW}px`}>
|
||||
{l.parts.map(([a, b]) => <span class={`enter-grow-x absolute inset-y-1 rounded-sm ${l.c}`} style={span(a, b)}></span>)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div class="flex items-center gap-3 border-t border-slate-200/70 pt-2">
|
||||
<div class="w-[96px] shrink-0">
|
||||
<div class="text-[11.5px] font-semibold leading-tight text-slate-900">Can send</div>
|
||||
<div class="text-[10px] leading-tight text-sky-700">14:00 to 17:00</div>
|
||||
</div>
|
||||
<div class="relative h-5 rounded bg-slate-50 ring-1 ring-inset ring-slate-300" style={`width:${AW}px`}>
|
||||
<span class="enter-grow-x absolute inset-y-1 rounded-sm bg-sky-600" style={span(14 * 60, 17 * 60)}></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative h-4 font-mono text-[9.5px] text-slate-400" style={`margin-left:108px; width:${AW}px`}>
|
||||
{[6, 10, 14, 18, 22].map((h) => <span class="absolute -translate-x-1/2" style={`left:${ax(h * 60)}px`}>{String(h).padStart(2, '0')}:00</span>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScaledStage>
|
||||
@@ -0,0 +1,128 @@
|
||||
---
|
||||
import ScaledStage from '../../ScaledStage.astro';
|
||||
import Glyph from '../Glyph.astro';
|
||||
import Ico from '../campaigns/Ico.astro';
|
||||
import { WINDOW, FLOAT, EYEBROW } from '../../../lib/scene';
|
||||
|
||||
/**
|
||||
* How a campaign email leaves: a worker signs in to each mailbox at its own
|
||||
* provider (the Gmail API, Microsoft Graph, or SMTP for anything else, as
|
||||
* internal/app/worker/wmail/send.go does), the provider delivers from its own
|
||||
* servers, and the recipient sees mail from your mailbox and your domain.
|
||||
*/
|
||||
const calls = [
|
||||
{ t: '09:41:07', from: 'alex@northwind.dev', to: 'gmail.googleapis.com', op: 'messages.send', code: '200' },
|
||||
{ t: '09:43:52', from: 'sam@northwind.io', to: 'graph.microsoft.com', op: 'sendMail', code: '202' },
|
||||
{ t: '09:46:15', from: 'ops@northwind.co', to: 'smtp.northwind.co:587', op: 'AUTH · DATA', code: '250' },
|
||||
{ t: '09:49:30', from: 'mia@northwind.dev', to: 'gmail.googleapis.com', op: 'messages.send', code: '200' },
|
||||
];
|
||||
const providers = [
|
||||
{ mark: 'google', name: 'Google Workspace', via: 'Gmail API', on: true },
|
||||
{ mark: 'microsoft', name: 'Microsoft 365', via: 'Microsoft Graph' },
|
||||
{ mark: 'imap', name: 'Your SMTP host', via: 'SMTP' },
|
||||
] as const;
|
||||
// Stage coordinates for the connectors and the mail moving along them.
|
||||
const ys = [195, 287, 379];
|
||||
const signIn = ys.map((y) => `M404,190 C448,190 446,${y} 492,${y}`);
|
||||
const deliver = "M776,195 C820,195 812,214 862,214";
|
||||
const route = "M404,190 C448,190 446,195 492,195 L776,195 C820,195 812,214 862,214";
|
||||
---
|
||||
<ScaledStage w={1240} h={472} photo="paint-teal">
|
||||
<!-- The connectors, under everything. -->
|
||||
<svg class="absolute inset-0" width="1240" height="472" fill="none">
|
||||
{signIn.map((d, i) => <path d={d} stroke={i === 0 ? '#0ea5e9' : '#94a3b8'} stroke-width="1.5" stroke-dasharray="4 4" />)}
|
||||
<path d={deliver} stroke="#0ea5e9" stroke-width="1.5" stroke-dasharray="4 4" />
|
||||
</svg>
|
||||
<span class="pa-dot" style={`offset-path: path('${route}');`}></span>
|
||||
<span class="pa-dot" style={`offset-path: path('${route}'); animation-delay: -2.75s;`}></span>
|
||||
<span class="absolute left-[402px] top-[150px] rounded-full bg-white/90 px-2 text-[10.5px] font-medium leading-[18px] text-slate-600 ring-1 ring-slate-200">1 · signs in</span>
|
||||
<span class="absolute left-[778px] top-[158px] rounded-full bg-white/90 px-2 text-[10.5px] font-medium leading-[18px] text-slate-600 ring-1 ring-slate-200">2 · delivers</span>
|
||||
|
||||
<!-- The worker: one sign-in address per mailbox, kept for as long as it can be. -->
|
||||
<div class={`${FLOAT} left-[40px] top-[64px] w-[364px] overflow-hidden`}>
|
||||
<div class="px-4 py-3 border-b border-slate-200/70 flex items-center gap-2.5">
|
||||
<span class="size-8 rounded-lg bg-slate-100 text-slate-600 flex items-center justify-center"><Glyph name="server" size={16} /></span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[12.5px] font-medium text-slate-900">Warmbly worker</div>
|
||||
<div class="font-mono text-[10.5px] text-slate-400">worker-fra-2 · eu-central</div>
|
||||
</div>
|
||||
<span class="inline-flex h-5 items-center gap-1.5 rounded-full border border-emerald-100 bg-emerald-50 px-2 text-[10px] font-semibold uppercase tracking-wide text-emerald-700"><span class="size-1.5 rounded-full bg-emerald-500 animate-pulse"></span>healthy</span>
|
||||
</div>
|
||||
<dl class="px-4 py-2.5 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-[12px]">
|
||||
<dt class="text-slate-400">Signs in from</dt><dd class="text-right font-mono text-slate-800">203.0.113.24</dd>
|
||||
<dt class="text-slate-400">Mailboxes</dt><dd class="text-right font-mono text-slate-800">38</dd>
|
||||
<dt class="text-slate-400">alex@northwind.dev</dt><dd class="text-right text-slate-800">on this worker for 214 days</dd>
|
||||
</dl>
|
||||
<div class="mx-3 mb-3 rounded-md bg-slate-900 px-3 py-2.5 font-mono text-[10.5px] leading-[1.75]">
|
||||
{calls.map((c, i) => (
|
||||
<div class="enter-fade flex gap-2 whitespace-nowrap" style={`--d:${i * 180}ms`}>
|
||||
<span class="text-slate-500">{c.t}</span>
|
||||
<span class="text-sky-300">{c.to}</span>
|
||||
<span class="text-slate-400">{c.op}</span>
|
||||
<span class="ml-auto text-emerald-400">{c.code}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The mailbox providers, who deliver. -->
|
||||
<div class={`${FLOAT} left-[492px] top-[112px] w-[284px] overflow-hidden`}>
|
||||
<div class="px-4 py-2.5 border-b border-slate-200/70"><span class={EYEBROW}>Your mailbox provider</span></div>
|
||||
{providers.map((p) => (
|
||||
<div class={`px-4 h-[92px] flex items-center gap-3 border-b border-slate-100 last:border-b-0 ${'on' in p ? 'bg-sky-50/50' : ''}`}>
|
||||
<span class="size-9 shrink-0 rounded-lg border border-slate-200 bg-white flex items-center justify-center"><Glyph mark={p.mark} size={18} /></span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[12.5px] font-medium text-slate-900">{p.name}</div>
|
||||
<div class="text-[11px] text-slate-400">Reached through {p.via}</div>
|
||||
<div class="mt-1 text-[11px] text-slate-600">Delivers from its own servers</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<!-- What the recipient sees. -->
|
||||
<div class={`${WINDOW} left-[862px] top-[56px] w-[420px] h-[560px]`}>
|
||||
<div class="h-11 px-4 flex items-center gap-1 border-b border-slate-200 text-slate-500">
|
||||
<span class="size-8 inline-flex items-center justify-center"><Ico name="arrowLeft" size={15} /></span>
|
||||
<span class="size-8 inline-flex items-center justify-center"><Glyph name="archive" size={14} /></span>
|
||||
<span class="size-8 inline-flex items-center justify-center"><Glyph name="alert" size={14} /></span>
|
||||
<span class="size-8 inline-flex items-center justify-center"><Ico name="trash" size={14} /></span>
|
||||
</div>
|
||||
<div class="px-5 pt-4 pr-12">
|
||||
<div class="text-[17px] text-slate-900">Quick question about Orbit</div>
|
||||
<div class="mt-3 flex items-start gap-2.5">
|
||||
<span class="size-8 shrink-0 rounded-full bg-sky-600 text-white text-[12px] font-medium flex items-center justify-center">A</span>
|
||||
<div class="min-w-0">
|
||||
<div class="text-[12.5px]"><span class="font-semibold text-slate-900">Alex Kim</span> <span class="text-slate-500"><alex@northwind.dev></span></div>
|
||||
<div class="inline-flex items-center gap-0.5 text-[11.5px] text-slate-500">to me <span class="rotate-180 inline-flex"><Glyph name="chevronDown" size={10} /></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 ml-10 rounded-md border border-slate-200 bg-white px-3 py-2.5 shadow-[0_8px_24px_-12px_rgba(15,23,42,0.3)]">
|
||||
<dl class="grid grid-cols-[64px_1fr] gap-x-3 gap-y-1 text-[11.5px]">
|
||||
<dt class="text-right text-slate-400">from:</dt><dd class="text-slate-800">Alex Kim <alex@northwind.dev></dd>
|
||||
<dt class="text-right text-slate-400">to:</dt><dd class="text-slate-800">dana@orbit.app</dd>
|
||||
<dt class="text-right text-slate-400">date:</dt><dd class="text-slate-800">Sep 14, 2026, 9:41 AM</dd>
|
||||
<dt class="text-right text-slate-400">mailed-by:</dt><dd class="font-medium text-slate-900">northwind.dev</dd>
|
||||
<dt class="text-right text-slate-400">signed-by:</dt><dd class="font-medium text-slate-900">northwind.dev</dd>
|
||||
<dt class="text-right text-slate-400">security:</dt><dd class="inline-flex items-center gap-1 text-slate-800"><Ico name="lock" size={11} class="text-slate-400" />Standard encryption (TLS)</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="mt-4 ml-10 space-y-2 text-[12.5px] leading-[1.6] text-slate-700">
|
||||
<p>Hi Dana,</p>
|
||||
<p>Saw Orbit just opened a second office in Austin. Teams growing that fast usually find outbound is the first thing to strain.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScaledStage>
|
||||
|
||||
<style>
|
||||
.pa-dot {
|
||||
position: absolute; left: 0; top: 0; width: 9px; height: 9px; border-radius: 9999px;
|
||||
background: #0ea5e9; box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.2); offset-rotate: 0deg; opacity: 0;
|
||||
animation: pa-move 5.5s cubic-bezier(0.45, 0, 0.35, 1) infinite;
|
||||
}
|
||||
@keyframes pa-move {
|
||||
0% { offset-distance: 0%; opacity: 0; }
|
||||
8%, 88% { opacity: 1; }
|
||||
100% { offset-distance: 100%; opacity: 0; }
|
||||
}</style>
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
import ScaledStage from '../../ScaledStage.astro';
|
||||
import Glyph from '../Glyph.astro';
|
||||
import Ico from '../campaigns/Ico.astro';
|
||||
import { WINDOW, FLOAT, EYEBROW, TAB } from '../../../lib/scene';
|
||||
|
||||
/**
|
||||
* Further down the Sending tab: the ranges themselves (lunch, daily target,
|
||||
* hourly ceiling, delay), as PairFields of our NumberInput with steppers.
|
||||
* Over it, the workday each morning rolls out of them, one day after another.
|
||||
*/
|
||||
const tabs = [
|
||||
{ label: 'Overview', icon: 'gauge' },
|
||||
{ label: 'Deliverability', icon: 'mailCheck' },
|
||||
{ label: 'Analytics', icon: 'barChart' },
|
||||
{ label: 'Warmup', icon: 'flame' },
|
||||
{ label: 'Sending', icon: 'clock', active: true },
|
||||
{ label: 'Settings', icon: 'settings' },
|
||||
] as const;
|
||||
const volume = [
|
||||
{ label: 'Daily cold-email target range', a: '28', b: '32', suffix: '/ day', hint: 'Chosen once per local day and kept stable across retries. Mailbox and campaign maximums can lower it.' },
|
||||
{ label: 'Cold emails per hour', a: '5', b: '9', suffix: '/ hour', hint: 'Stops a whole day’s allowance landing in one burst before lunch.' },
|
||||
{ label: 'Delay between emails', a: '90', b: '420', suffix: 'seconds', hint: 'Drawn fresh for every send, so the intervals stay irregular. Currently 1m 30s to 7m.' },
|
||||
];
|
||||
// Three mornings, three different days out of the same ranges.
|
||||
const rolls = [
|
||||
{ day: 'Mon', hours: ['09:14', '17:41'], lunch: '12:32–13:19', hourly: 7, target: 31 },
|
||||
{ day: 'Tue', hours: ['09:22', '17:19'], lunch: '12:08–12:51', hourly: 6, target: 29 },
|
||||
{ day: 'Wed', hours: ['09:05', '17:52'], lunch: '13:02–13:58', hourly: 8, target: 32 },
|
||||
];
|
||||
const clockField = 'h-9 px-2.5 rounded-md border border-slate-200 bg-white text-[12.5px] text-slate-900 tabular-nums flex items-center w-full';
|
||||
---
|
||||
<ScaledStage w={760} h={570} photo="reeded-glass">
|
||||
<div class={`${WINDOW} left-[40px] top-[40px] w-[740px] h-[560px]`}>
|
||||
<div class="px-3 flex items-center gap-1 border-b border-slate-200">
|
||||
{tabs.map((t) => (
|
||||
<span class={`${TAB} ${'active' in t ? 'font-medium text-slate-900' : 'text-slate-500'}`}>
|
||||
<Glyph name={t.icon} size={14} />{t.label}
|
||||
{'active' in t && <span class="absolute bottom-0 left-1.5 right-1.5 h-0.5 rounded-full bg-sky-600"></span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div class="divide-y divide-slate-200/60 pr-5">
|
||||
<div class="px-5 py-5 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class={EYEBROW}>Lunch break</span>
|
||||
<span class="relative inline-flex h-[18px] w-8 items-center rounded-full bg-sky-600"><span class="inline-block size-3.5 translate-x-[15px] rounded-full bg-white shadow-sm"></span></span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
<div>
|
||||
<div class="mb-1 flex items-center gap-1.5"><Ico name="coffee" size={14} class="text-slate-400" /><span class="text-[12px] font-medium text-slate-700">Starts somewhere between</span></div>
|
||||
<div class="flex items-center gap-2"><span class={clockField}>12:00</span><span class="shrink-0 text-[11.5px] text-slate-400">to</span><span class={clockField}>13:30</span></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-[12px] font-medium text-slate-700">Lasts</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{['30', '60'].map((v, i) => (
|
||||
<>
|
||||
{i === 1 && <span class="shrink-0 text-[11.5px] text-slate-400">to</span>}
|
||||
<span class="relative inline-flex h-9 w-full items-center rounded-md border border-slate-200 bg-white">
|
||||
<span class="flex-1 px-2.5 text-right text-[12.5px] tabular-nums text-slate-900">{v}</span>
|
||||
{i === 1 && <span class="pr-2 text-[11px] text-slate-400">minutes</span>}
|
||||
<span class="flex flex-col self-stretch border-l border-slate-200 text-slate-400"><span class="flex-1 px-1 flex items-center"><Ico name="chevronUp" size={11} /></span><span class="flex-1 px-1 flex items-center border-t border-slate-200"><Glyph name="chevronDown" size={11} /></span></span>
|
||||
</span>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 py-5 space-y-4">
|
||||
<div class={EYEBROW}>Volume and spacing</div>
|
||||
{volume.map((f) => (
|
||||
<div>
|
||||
<div class="mb-1 text-[12px] font-medium text-slate-700">{f.label}</div>
|
||||
<div class="flex items-center gap-2 max-w-[396px]">
|
||||
{[f.a, f.b].map((v, i) => (
|
||||
<>
|
||||
{i === 1 && <span class="shrink-0 text-[11.5px] text-slate-400">to</span>}
|
||||
<span class="relative inline-flex h-9 w-full items-center rounded-md border border-slate-200 bg-white">
|
||||
<span class="flex-1 px-2.5 text-right text-[12.5px] tabular-nums text-slate-900">{v}</span>
|
||||
{i === 1 && <span class="pr-2 text-[11px] text-slate-400 whitespace-nowrap">{f.suffix}</span>}
|
||||
<span class="flex flex-col self-stretch border-l border-slate-200 text-slate-400"><span class="flex-1 px-1 flex items-center"><Ico name="chevronUp" size={11} /></span><span class="flex-1 px-1 flex items-center border-t border-slate-200"><Glyph name="chevronDown" size={11} /></span></span>
|
||||
</span>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
<p class="mt-1 max-w-[396px] text-[10.5px] leading-relaxed text-slate-400">{f.hint}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Each morning a new workday is rolled from the ranges. -->
|
||||
<div class={`${FLOAT} left-[474px] top-[236px] w-[264px] overflow-hidden`}>
|
||||
<div class="relative h-[150px]">
|
||||
{rolls.map((r, i) => (
|
||||
<div class={`rg-roll absolute inset-0 px-4 py-3.5`} style={`--i:${i}`}>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class={EYEBROW}>Today’s workday</span>
|
||||
<span class="inline-flex h-5 items-center rounded bg-sky-50 px-1.5 text-[10.5px] font-medium text-sky-700">{r.day}</span>
|
||||
</div>
|
||||
<div class="mt-2.5 space-y-1.5 text-[12.5px] text-slate-700">
|
||||
<div class="flex items-center gap-1.5"><Glyph name="clock" size={13} class="text-slate-400" /><b class="font-semibold tabular-nums text-slate-900">{r.hours[0]}</b><span class="text-slate-400">–</span><b class="font-semibold tabular-nums text-slate-900">{r.hours[1]}</b></div>
|
||||
<div class="flex items-center gap-1.5"><Ico name="coffee" size={13} class="text-slate-400" /><span class="tabular-nums">{r.lunch}</span></div>
|
||||
<div class="flex items-center gap-1.5"><Glyph name="gauge" size={13} class="text-slate-400" />max {r.hourly}/hour<span class="ml-auto font-mono text-[11px] text-slate-500">target {r.target}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 border-t border-slate-200/70 bg-slate-50/60 px-4 py-2 text-[10.5px] text-slate-500">
|
||||
<Glyph name="info" size={12} class="text-slate-400" />Saved ranges apply from the next local day.
|
||||
</div>
|
||||
</div>
|
||||
</ScaledStage>
|
||||
|
||||
<style>
|
||||
/* Three days on a 12s loop, 4s each. At rest (reduced motion) Monday shows. */
|
||||
.rg-roll { opacity: 0; }
|
||||
.rg-roll[style*='--i:0'] { opacity: 1; }
|
||||
.rg-roll { animation: rg-roll 12s ease-in-out infinite; animation-delay: calc(var(--i) * 4s - 0.4s); }
|
||||
@keyframes rg-roll {
|
||||
0% { opacity: 0; transform: translateY(6px); }
|
||||
4%, 30% { opacity: 1; transform: none; }
|
||||
34%, 100% { opacity: 0; transform: translateY(-6px); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
import ScaledStage from '../../ScaledStage.astro';
|
||||
import Glyph from '../Glyph.astro';
|
||||
import { WINDOW, FLOAT, EYEBROW, TOPBAR, SECTIONBAR, ACTION_GHOST, ACTION_PRIMARY } from '../../../lib/scene';
|
||||
|
||||
/**
|
||||
* Accounts (web/src/app/app/emails/page.tsx) narrowed to one campaign's
|
||||
* senders: each mailbox in its own timezone with its own rolled day, and the
|
||||
* campaign's day as the sum of them (SendPlanCard's working, floating).
|
||||
*/
|
||||
type Row = { addr: string; mark: 'google' | 'microsoft' | 'imap'; tz: string; hours: string; plan: number; sent: number; warming?: boolean; closed?: boolean };
|
||||
const rows: Row[] = [
|
||||
{ addr: 'alex@northwind.dev', mark: 'google', tz: 'London', hours: '09:14–17:41', plan: 31, sent: 18 },
|
||||
{ addr: 'sam@northwind.io', mark: 'microsoft', tz: 'London', hours: '09:22–17:19', plan: 29, sent: 16 },
|
||||
{ addr: 'ops@northwind.co', mark: 'imap', tz: 'Berlin', hours: '09:08–17:30', plan: 30, sent: 21 },
|
||||
{ addr: 'lea@northwind.io', mark: 'microsoft', tz: 'New York', hours: '09:17–17:48', plan: 28, sent: 2 },
|
||||
{ addr: 'jon@northwind.dev', mark: 'google', tz: 'Denver', hours: '09:11–17:26', plan: 32, sent: 0, closed: true },
|
||||
{ addr: 'mia@northwind.dev', mark: 'google', tz: 'London', hours: '09:05–17:52', plan: 5, sent: 4, warming: true },
|
||||
];
|
||||
const total = rows.reduce((s, r) => s + r.plan, 0);
|
||||
const sentTotal = rows.reduce((s, r) => s + r.sent, 0);
|
||||
const th = 'px-3 py-2 text-[10px] font-medium text-slate-400 uppercase tracking-[0.14em] text-left';
|
||||
---
|
||||
<ScaledStage w={760} h={620} photo="lake-mist">
|
||||
<div class={`${WINDOW} left-[40px] top-[40px] w-[740px] h-[600px]`}>
|
||||
<div class={TOPBAR}>
|
||||
<span class={EYEBROW}>Accounts</span>
|
||||
<span class="h-4 w-px bg-slate-200"></span>
|
||||
<span class="text-[12.5px] text-slate-600">tag: outbound</span>
|
||||
<div class="ml-auto flex items-center gap-1.5 pr-6">
|
||||
<span class={ACTION_GHOST}><Glyph name="globe" size={12} />Sending domains</span>
|
||||
<span class={ACTION_PRIMARY}><Glyph name="plus" size={12} />Add account</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class={SECTIONBAR}>
|
||||
<span class={EYEBROW}>Mailboxes</span>
|
||||
<span class="font-mono text-[10.5px] text-slate-400 tabular-nums">6</span>
|
||||
<span class="ml-auto pr-6 inline-flex items-center gap-1.5 text-[11px] text-slate-400"><Glyph name="clock" size={12} />Each in its own workday</span>
|
||||
</div>
|
||||
<table class="w-full table-fixed">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-200">
|
||||
<th class={`${th} pl-5`}>Mailbox</th>
|
||||
<th class={`${th} w-[150px]`}>Workday today</th>
|
||||
<th class={`${th} w-[176px]`}>Sent today</th>
|
||||
<th class="w-[24px]"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => (
|
||||
<tr class="h-[48px] border-b border-slate-200/60">
|
||||
<td class="pl-5 pr-3">
|
||||
<div class="flex items-center gap-2.5 min-w-0">
|
||||
<span class="inline-flex size-6 shrink-0 items-center justify-center rounded-full border border-slate-200 bg-white"><Glyph mark={r.mark} size={r.mark === 'imap' ? 12 : 13} /></span>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-[12.5px] font-medium leading-tight text-slate-900">{r.addr}</div>
|
||||
<div class="mt-0.5 text-[11px] leading-tight">
|
||||
{r.warming
|
||||
? <span class="inline-flex items-center gap-1 text-orange-600"><Glyph name="flame" size={11} />Warming · 5 cold a day at most</span>
|
||||
: <span class="text-slate-400">Cap 50 · rolled {r.plan} today</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3">
|
||||
<div class="font-mono text-[11.5px] tabular-nums text-slate-700">{r.hours}</div>
|
||||
<div class="text-[10.5px] text-slate-400">{r.tz}{r.closed ? ' · opens later' : ''}</div>
|
||||
</td>
|
||||
<td class="px-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-1.5 flex-1 rounded-full bg-slate-100 overflow-hidden"><div class={`enter-grow-x h-full rounded-full ${r.warming ? 'bg-orange-400' : 'bg-sky-500'}`} style={`width:${(r.sent / r.plan) * 100}%; --d:${i * 90}ms`}></div></div>
|
||||
<span class="w-[46px] text-right font-mono text-[11.5px] tabular-nums text-slate-900">{i === 0 ? <span data-tick="18,24,3400">18</span> : r.sent}<span class="text-slate-400">/{r.plan}</span></span>
|
||||
</div>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
))} </tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- The campaign's day, worked out from those mailboxes. -->
|
||||
<div class={`${FLOAT} left-[424px] top-[446px] w-[312px] overflow-hidden`}>
|
||||
<div class="px-4 pt-3 pb-2 flex items-baseline gap-1.5 border-b border-slate-200/70">
|
||||
<span class="text-[20px] font-semibold leading-none tabular-nums text-slate-900">{total}</span>
|
||||
<span class="text-[11.5px] text-slate-600">today</span>
|
||||
<span class="ml-auto text-[10.5px] text-slate-400">From your settings to today</span>
|
||||
</div>
|
||||
<div class="px-4 py-1.5 divide-y divide-slate-100 text-[12px]">
|
||||
<div class="h-7 flex items-center"><span class="text-slate-700">Mailbox caps added up</span><span class="ml-auto font-mono text-[11.5px] tabular-nums text-slate-700">300</span></div>
|
||||
<div class="h-7 flex items-center gap-2"><span class="text-slate-700">Sending behaviour plan</span><span class="text-[10.5px] text-slate-400">5 mailboxes</span><span class="ml-auto font-mono text-[11.5px] tabular-nums text-rose-600">−100</span></div>
|
||||
<div class="h-7 flex items-center gap-2"><span class="text-slate-700">Easing out of warmup</span><span class="text-[10.5px] text-slate-400">1 mailbox</span><span class="ml-auto font-mono text-[11.5px] tabular-nums text-rose-600">−45</span></div>
|
||||
<div class="h-7 flex items-center"><span class="font-medium text-slate-900">Today’s plan</span><span class="ml-auto font-mono text-[11.5px] font-medium tabular-nums text-slate-900">{total}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</ScaledStage>
|
||||
@@ -0,0 +1,184 @@
|
||||
---
|
||||
import ScaledStage from '../../ScaledStage.astro';
|
||||
import Glyph from '../Glyph.astro';
|
||||
import Ico from '../campaigns/Ico.astro';
|
||||
import { WINDOW, FLOAT, EYEBROW, TAB } from '../../../lib/scene';
|
||||
|
||||
/**
|
||||
* The mailbox drawer's Sending tab (emails/SendingBehaviorTab.tsx): the
|
||||
* Variable sending plan switch, today's rolled workday (hours, break,
|
||||
* spacing band, hourly ceiling, how much of the target is spent) drawn on
|
||||
* the day it describes, then the working days and workday ranges below.
|
||||
* The campaign's plan floats over it with this mailbox's share of the day.
|
||||
*/
|
||||
const tabs = [
|
||||
{ label: 'Overview', icon: 'gauge' },
|
||||
{ label: 'Deliverability', icon: 'mailCheck' },
|
||||
{ label: 'Analytics', icon: 'barChart' },
|
||||
{ label: 'Warmup', icon: 'flame' },
|
||||
{ label: 'Sending', icon: 'clock', active: true },
|
||||
{ label: 'Settings', icon: 'settings' },
|
||||
] as const;
|
||||
|
||||
// Today's roll, local to Europe/London, drawn on a 06:00 to 20:00 axis.
|
||||
const A0 = 6 * 60, A1 = 20 * 60;
|
||||
const day = { start: 9 * 60 + 14, end: 17 * 60 + 41, lunch: 12 * 60 + 32, lunchEnd: 13 * 60 + 19, target: 31, hourly: 7 };
|
||||
const now = 14 * 60 + 24;
|
||||
const W = 652;
|
||||
const x = (m: number) => ((m - A0) / (A1 - A0)) * W;
|
||||
const clock = (m: number) => `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(Math.round(m % 60)).padStart(2, '0')}`;
|
||||
// 31 sends paced across the open minutes with jitter; a seed keeps every build identical.
|
||||
let seed = 11;
|
||||
const rand = () => ((seed = (seed * 16807) % 2147483647) / 2147483647);
|
||||
const open = day.end - day.start - (day.lunchEnd - day.lunch);
|
||||
const sends: number[] = [];
|
||||
for (let i = 0; i < day.target; i++) {
|
||||
let m = day.start + ((i + 0.5) / day.target) * open + (rand() - 0.5) * 9;
|
||||
if (m >= day.lunch) m += day.lunchEnd - day.lunch;
|
||||
sends.push(Math.round(m));
|
||||
}
|
||||
const sent = sends.filter((t) => t < now).length;
|
||||
const nextSend = sends.find((t) => t >= now) ?? now;
|
||||
const left = day.target - sent;
|
||||
const hours = [6, 8, 10, 12, 14, 16, 18, 20];
|
||||
const weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
const field = 'h-9 px-2.5 rounded-md border border-slate-200 bg-white text-[12.5px] text-slate-900 tabular-nums flex items-center w-full';
|
||||
---
|
||||
<ScaledStage w={760} h={820} photo="lake-motion">
|
||||
<div class={`${WINDOW} left-[40px] top-[40px] w-[740px] h-[800px]`}>
|
||||
<div class="h-14 px-5 flex items-center gap-3 border-b border-slate-200">
|
||||
<div class="relative size-8 shrink-0 rounded-lg bg-sky-50 text-sky-700 flex items-center justify-center text-[11px] font-semibold">
|
||||
AK
|
||||
<span class="absolute -right-1 -bottom-1 inline-flex size-4 items-center justify-center rounded border border-slate-200 bg-white ring-2 ring-white"><Glyph mark="google" size={10} /></span>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-[13px] font-medium text-slate-900">alex@northwind.dev</div>
|
||||
<div class="truncate text-[10.5px] text-slate-400">Google Workspace</div>
|
||||
</div>
|
||||
<span class="inline-flex h-5 items-center rounded-full border border-emerald-100 bg-emerald-50 px-2 text-[10px] font-semibold uppercase tracking-wide text-emerald-700">active</span>
|
||||
<span class="mr-6 inline-flex size-7 items-center justify-center rounded-md text-slate-400"><Glyph name="x" size={16} /></span>
|
||||
</div>
|
||||
<div class="px-3 flex items-center gap-1 border-b border-slate-200">
|
||||
{tabs.map((t) => (
|
||||
<span class={`${TAB} ${'active' in t ? 'font-medium text-slate-900' : 'text-slate-500'}`}>
|
||||
<Glyph name={t.icon} size={14} />{t.label}
|
||||
{'active' in t && <span class="absolute bottom-0 left-1.5 right-1.5 h-0.5 rounded-full bg-sky-600"></span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-slate-200/60 pr-5">
|
||||
<!-- On/off -->
|
||||
<div class="px-5 py-4 flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<span class="size-8 rounded-lg bg-sky-50 text-sky-600 flex items-center justify-center"><Glyph name="clock" size={16} /></span>
|
||||
<div>
|
||||
<div class="text-[12.5px] font-medium text-slate-900">Variable sending plan</div>
|
||||
<div class="text-[11px] text-slate-400">Hours, volume and spacing are re-rolled each day</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="relative inline-flex h-[18px] w-8 items-center rounded-full bg-sky-600"><span class="inline-block size-3.5 translate-x-[15px] rounded-full bg-white shadow-sm"></span></span>
|
||||
</div>
|
||||
|
||||
<!-- Today's workday -->
|
||||
<div class="px-5 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class={EYEBROW}>Today’s workday</span>
|
||||
<span class="font-mono text-[10.5px] text-slate-400">Europe/London</span>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-[12.5px] text-slate-700">
|
||||
<span class="inline-flex items-center gap-1.5"><Glyph name="clock" size={14} class="text-slate-400" /><b class="font-semibold text-slate-900 tabular-nums">{clock(day.start)}</b><span class="text-slate-400">–</span><b class="font-semibold text-slate-900 tabular-nums">{clock(day.end)}</b></span>
|
||||
<span class="inline-flex items-center gap-1.5"><Ico name="coffee" size={14} class="text-slate-400" /><span class="tabular-nums">{clock(day.lunch)}–{clock(day.lunchEnd)}</span></span>
|
||||
<span class="inline-flex items-center gap-1.5"><Ico name="timer" size={14} class="text-slate-400" />1m 30s–7m apart</span>
|
||||
<span class="inline-flex items-center gap-1.5"><Glyph name="gauge" size={14} class="text-slate-400" />max {day.hourly}/hour</span>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center justify-between text-[12px] text-slate-600">
|
||||
<span><b class="font-semibold text-slate-900 tabular-nums">{sent}</b> of <b class="font-semibold text-slate-900 tabular-nums">{day.target}</b> sent</span>
|
||||
<span class="text-slate-400 tabular-nums">{left} left in plan</span>
|
||||
</div>
|
||||
<div class="mt-1.5 h-1.5 rounded-full bg-slate-100 overflow-hidden"><div class="enter-grow-x h-full rounded-full bg-sky-500" style={`width:${(sent / day.target) * 100}%`}></div></div>
|
||||
|
||||
<!-- The same day, hour by hour. -->
|
||||
<div class="mt-5 rounded-md border border-slate-200 bg-slate-50/50 px-3 pt-3 pb-2">
|
||||
<div class="relative h-14" style={`width:${W}px`}>
|
||||
<div class="absolute inset-y-0 rounded bg-sky-50 ring-1 ring-inset ring-sky-100" style={`left:${x(day.start)}px; width:${x(day.end) - x(day.start)}px`}></div>
|
||||
<div class="absolute inset-y-0 bg-[repeating-linear-gradient(135deg,#e2e8f0_0,#e2e8f0_3px,#fff_3px,#fff_7px)]" style={`left:${x(day.lunch)}px; width:${x(day.lunchEnd) - x(day.lunch)}px`}></div>
|
||||
{sends.map((t, i) => (
|
||||
<span class={`enter-grow-y absolute bottom-2 top-2 w-[2px] rounded-full ${t < now ? 'bg-sky-500' : 'bg-slate-300'}`} style={`left:${x(t)}px; --d:${i * 35}ms`}></span>
|
||||
))}
|
||||
<span class="wd-next absolute top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-sky-500" style={`left:${x(nextSend) + 1}px`}></span>
|
||||
<div class="absolute -top-1 -bottom-1 w-px bg-rose-400" style={`left:${x(now)}px`}>
|
||||
<span class="absolute -top-0.5 left-1.5 whitespace-nowrap rounded bg-rose-50 px-1 font-mono text-[9.5px] text-rose-600">{clock(now)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative mt-1 h-4 font-mono text-[10px] text-slate-400" style={`width:${W}px`}>
|
||||
{hours.map((h) => <span class="absolute -translate-x-1/2" style={`left:${x(h * 60)}px`}>{String(h).padStart(2, '0')}:00</span>)}
|
||||
</div>
|
||||
<div class="mt-1.5 flex items-center gap-4 border-t border-slate-200/70 pt-2 text-[10.5px] text-slate-500">
|
||||
<span class="inline-flex items-center gap-1.5"><span class="h-3 w-[2px] rounded-full bg-sky-500"></span>Sent</span>
|
||||
<span class="inline-flex items-center gap-1.5"><span class="h-3 w-[2px] rounded-full bg-slate-300"></span>Planned</span>
|
||||
<span class="inline-flex items-center gap-1.5"><span class="size-2.5 rounded-sm bg-[repeating-linear-gradient(135deg,#e2e8f0_0,#e2e8f0_2px,#fff_2px,#fff_4px)] ring-1 ring-slate-200"></span>Lunch</span>
|
||||
<span class="ml-auto font-mono text-slate-400">next send {clock(nextSend)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Working days -->
|
||||
<div class="px-5 py-5 space-y-2">
|
||||
<div class={EYEBROW}>Working days</div>
|
||||
<div class="grid grid-cols-7 gap-1.5">
|
||||
{weekdays.map((d, i) => (
|
||||
<span class={`h-12 rounded-md border flex flex-col items-center justify-center ${i < 5 ? 'border-sky-500 bg-sky-50 text-sky-700' : 'border-slate-200 bg-white text-slate-500'}`}>
|
||||
<span class="text-[11px] font-medium">{d}</span>
|
||||
<span class={`mt-1 size-1.5 rounded-full ${i < 5 ? 'bg-sky-500' : 'bg-slate-300'}`}></span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p class="text-[10.5px] leading-relaxed text-slate-400">Days this mailbox sends anything at all, cold outreach and warmup alike.</p>
|
||||
</div>
|
||||
|
||||
<!-- Workday ranges -->
|
||||
<div class="px-5 py-5 space-y-5">
|
||||
<div class={EYEBROW}>Workday</div>
|
||||
{[['sunrise', 'Starts somewhere between', '09:03', '09:27'], ['sunset', 'Finishes somewhere between', '17:18', '17:56']].map(([icon, label, a, b]) => (
|
||||
<div>
|
||||
<div class="mb-1 flex items-center gap-1.5"><Ico name={icon as never} size={14} class="text-slate-400" /><span class="text-[12px] font-medium text-slate-700">{label}</span></div>
|
||||
<div class="flex items-center gap-2"><span class={field}>{a}</span><span class="shrink-0 text-[11.5px] text-slate-400">to</span><span class={field}>{b}</span></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The campaign's plan, with this mailbox's rolled day in it. -->
|
||||
<div class={`${FLOAT} left-[384px] top-[628px] w-[350px] overflow-hidden`}>
|
||||
<div class="px-4 pt-3 pb-2 border-b border-slate-200/70">
|
||||
<div class="flex items-baseline gap-1.5">
|
||||
<span class={EYEBROW}>Today’s sending plan</span>
|
||||
<span class="ml-auto truncate text-[11px] text-slate-400">Series A founders, Q3</span>
|
||||
</div>
|
||||
<div class="mt-2 h-7 flex items-center gap-2 text-[12px]">
|
||||
<span class="text-slate-700">Sending behaviour plan</span>
|
||||
<span class="text-[10.5px] text-slate-400">1 mailbox</span>
|
||||
<span class="ml-auto font-mono text-[11.5px] tabular-nums text-rose-600">−19</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-4 py-2.5 flex items-center gap-2">
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate text-[12px] text-slate-900">alex@northwind.dev</span>
|
||||
<span class="block truncate text-[10.5px] text-slate-400">gap 2m</span>
|
||||
</span>
|
||||
<span class="shrink-0 font-mono text-[11px] tabular-nums text-slate-500">{sent}<span class="text-slate-300"> / </span><span class="text-slate-700">{day.target}</span><span class="text-slate-400"> of 50</span></span>
|
||||
<span class="shrink-0 inline-flex h-5 items-center rounded px-1.5 text-[10px] font-medium ring-1 bg-emerald-50 text-emerald-700 ring-emerald-200">Sending</span>
|
||||
</div>
|
||||
</div>
|
||||
</ScaledStage>
|
||||
|
||||
<style>
|
||||
/* The next planned send waits its turn. */
|
||||
.wd-next { box-shadow: 0 0 0 0 rgb(14 165 233 / 0.45); animation: wd-next 2.4s ease-out infinite; }
|
||||
@keyframes wd-next {
|
||||
0% { box-shadow: 0 0 0 0 rgb(14 165 233 / 0.45); }
|
||||
70%, 100% { box-shadow: 0 0 0 8px rgb(14 165 233 / 0); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,170 @@
|
||||
---
|
||||
import ScaledStage from '../../ScaledStage.astro';
|
||||
import Glyph from '../Glyph.astro';
|
||||
import Ico from './Ico.astro';
|
||||
import { WINDOW, FLOAT, EYEBROW } from '../../../lib/scene';
|
||||
|
||||
/**
|
||||
* A warmup thread as a pool partner's own mail client shows it: plain text,
|
||||
* labelled as a simulated diagnostic (the content bank in
|
||||
* internal/tasks/template.go), filed in the Warmbly folder. The card shows
|
||||
* what Warmbly checked when the copy arrived, one step at a time.
|
||||
*/
|
||||
const folders = [
|
||||
{ icon: 'inbox', name: 'Inbox', n: '24' },
|
||||
{ icon: 'star', name: 'Starred' },
|
||||
{ icon: 'send', name: 'Sent' },
|
||||
{ icon: 'file', name: 'Drafts', n: '2' },
|
||||
{ icon: 'archive', name: 'Archive' },
|
||||
{ icon: 'alert', name: 'Spam' },
|
||||
{ icon: 'folder', name: 'Warmbly', n: '6', active: true },
|
||||
] as const;
|
||||
const threads = [
|
||||
{ who: 'Alex Kim', n: 3, subject: 'Simulated diagnostic: fictional clock', preview: 'Agreed. The example is closed; no real meeting was scheduled.', time: '2:41 PM', active: true },
|
||||
{ who: 'Ravi Shah', n: 2, subject: 'Simulated diagnostic: sample count', preview: 'There are 5 cards in the hypothetical sample: 3 blue and 2 green.', time: '1:58 PM' },
|
||||
{ who: 'me, Lena', n: 2, subject: 'Simulated diagnostic: conditional wording', preview: 'The hypothetical status is pending review: no approval exists…', time: '11:20 AM' },
|
||||
{ who: 'Max Weber', n: 1, subject: 'Simulated diagnostic: summary correction', preview: 'A fictional checklist has 12 items: 9 marked complete and 3…', time: 'Oct 8' },
|
||||
{ who: 'me, Kai', n: 3, subject: 'Simulated diagnostic: plaintext label review', preview: 'Yes. Status: unknown and Next step: review are written in the…', time: 'Oct 8' },
|
||||
{ who: 'Sam Ortiz', n: 1, subject: 'Simulated diagnostic: fixed-offset date note', preview: 'On 2026-10-08 the fictional clock reads 09:00 UTC, or 11:00…', time: 'Oct 7' },
|
||||
] as const;
|
||||
const checks = [
|
||||
{ k: 'Token', v: 'Matched, now spent', icon: 'key' },
|
||||
{ k: 'Bound to', v: 'dana@helio.gen', icon: 'user' },
|
||||
{ k: 'Arrived in', v: 'Inbox', icon: 'inbox', chip: true },
|
||||
{ k: 'Mail host', v: 'Google Workspace', icon: 'server' },
|
||||
{ k: 'Then filed to', v: 'Warmbly folder', icon: 'folder' },
|
||||
] as const;
|
||||
---
|
||||
<ScaledStage w={1240} h={600} photo="sheer-peach">
|
||||
<div class={`${WINDOW} left-[40px] top-[40px] w-[1220px] h-[600px]`}>
|
||||
<!-- The partner's mail client. -->
|
||||
<div class="h-12 px-4 flex items-center gap-3 border-b border-slate-200 bg-slate-50/60">
|
||||
<span class="flex gap-1.5"><span class="size-2.5 rounded-full bg-slate-200"></span><span class="size-2.5 rounded-full bg-slate-200"></span><span class="size-2.5 rounded-full bg-slate-200"></span></span>
|
||||
<span class="ml-3 text-[13px] font-semibold text-slate-800">Mail</span>
|
||||
<div class="ml-24 h-8 w-[420px] rounded-md bg-white ring-1 ring-slate-200 flex items-center gap-2 px-2.5 text-[12.5px] text-slate-400"><Glyph name="search" size={14} />Search mail</div>
|
||||
<span class="ml-auto mr-12 inline-flex items-center gap-2 text-[12px] text-slate-500">dana@helio.gen<span class="flex size-7 items-center justify-center rounded-full bg-violet-100 text-[10.5px] font-semibold text-violet-700">DH</span></span>
|
||||
</div>
|
||||
<div class="flex h-[552px]">
|
||||
<div class="w-[196px] shrink-0 border-r border-slate-200 px-2 py-3">
|
||||
<span class="mb-3 ml-1 inline-flex h-8 items-center gap-1.5 rounded-md bg-slate-900 px-3 text-[12px] font-medium text-white"><Glyph name="penLine" size={13} />Compose</span>
|
||||
{folders.map((f) => (
|
||||
<div class={`flex h-8 items-center gap-2.5 rounded-md px-2.5 text-[12.5px] ${'active' in f && f.active ? 'bg-sky-50 font-medium text-sky-800' : 'text-slate-600'}`}>
|
||||
{f.icon === 'star' || f.icon === 'file' || f.icon === 'folder' ? <Ico name={f.icon} size={14} class={'active' in f && f.active ? 'text-sky-600' : 'text-slate-400'} /> : <Glyph name={f.icon} size={14} class="text-slate-400" />}
|
||||
{f.name}
|
||||
{'n' in f && <span class="ml-auto font-mono text-[10.5px] text-slate-400">{f.n}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="w-[340px] shrink-0 border-r border-slate-200">
|
||||
<div class="flex h-10 items-center gap-2 border-b border-slate-200 px-4">
|
||||
<span class="text-[13px] font-semibold text-slate-900">Warmbly</span>
|
||||
<span class="text-[11px] text-slate-400">Warmup mail, in and out</span>
|
||||
</div>
|
||||
{threads.map((t) => (
|
||||
<div class={`border-b border-slate-100 px-4 py-2.5 ${'active' in t && t.active ? 'bg-sky-50/70' : ''}`}>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="truncate text-[12.5px] font-medium text-slate-900">{t.who}</span>
|
||||
{t.n > 1 && <span class="font-mono text-[10.5px] text-slate-400">{t.n}</span>}
|
||||
<span class="ml-auto shrink-0 text-[11px] text-slate-400">{t.time}</span>
|
||||
</div>
|
||||
<div class="mt-0.5 truncate text-[12px] text-slate-700">{t.subject}</div>
|
||||
<div class="mt-0.5 truncate text-[11.5px] text-slate-400">{t.preview}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2 border-b border-slate-200 px-6 py-3.5">
|
||||
<span class="text-[16px] font-semibold text-slate-900">Simulated diagnostic: fictional clock</span>
|
||||
<span class="inline-flex h-5 items-center gap-1 rounded bg-slate-100 px-1.5 text-[10.5px] text-slate-600"><Ico name="folder" size={11} />Warmbly</span>
|
||||
</div>
|
||||
<div class="space-y-3 px-6 py-4">
|
||||
<div class="rounded-lg border border-slate-200 px-4 py-3">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<span class="flex size-7 items-center justify-center rounded-full bg-sky-100 text-[10.5px] font-semibold text-sky-700">AK</span>
|
||||
<span class="text-[12.5px] font-semibold text-slate-900">Alex Kim</span>
|
||||
<span class="text-[11.5px] text-slate-400">alex@northwind.dev</span>
|
||||
<span class="ml-auto text-[11px] text-slate-400">10:12 AM</span>
|
||||
</div>
|
||||
<p class="mt-2.5 max-w-[560px] font-mono text-[12.5px] leading-[1.65] text-slate-700">Simulated diagnostic. In this fictional example, the clock reads 14:00, not 15:00. Which time does the example use?</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-200 px-4 py-3">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<span class="flex size-7 items-center justify-center rounded-full bg-violet-100 text-[10.5px] font-semibold text-violet-700">DH</span>
|
||||
<span class="text-[12.5px] font-semibold text-slate-900">Dana Hart</span>
|
||||
<span class="text-[11.5px] text-slate-400">to Alex</span>
|
||||
<span class="ml-auto text-[11px] text-slate-400">12:47 PM</span>
|
||||
</div>
|
||||
<p class="mt-2.5 max-w-[560px] font-mono text-[12.5px] leading-[1.65] text-slate-700">The fictional example uses 14:00, not 15:00.</p>
|
||||
</div>
|
||||
<div class="cv-new rounded-lg border border-sky-200 bg-sky-50/30 px-4 py-3">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<span class="flex size-7 items-center justify-center rounded-full bg-sky-100 text-[10.5px] font-semibold text-sky-700">AK</span>
|
||||
<span class="text-[12.5px] font-semibold text-slate-900">Alex Kim</span>
|
||||
<span class="text-[11.5px] text-slate-400">Re: Simulated diagnostic: fictional clock</span>
|
||||
<span class="ml-auto text-[11px] text-slate-400">2:41 PM</span>
|
||||
</div>
|
||||
<p class="mt-2.5 max-w-[560px] font-mono text-[12.5px] leading-[1.65] text-slate-700">Agreed. The example is closed; no real meeting was scheduled.</p>
|
||||
<div class="mt-3 flex items-center gap-3 text-[11px] text-slate-400">
|
||||
<span class="inline-flex items-center gap-1"><Glyph name="mail" size={12} />text/plain</span>
|
||||
<span class="inline-flex items-center gap-1"><Ico name="eyeOff" size={12} />no tracking pixel</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-8 px-3 rounded-md border border-slate-200 inline-flex items-center gap-1.5 text-[12px] text-slate-600"><Glyph name="reply" size={13} />Reply</span>
|
||||
<span class="h-8 px-3 rounded-md border border-slate-200 inline-flex items-center gap-1.5 text-[12px] text-slate-600"><Ico name="arrowRight" size={13} />Forward</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- What Warmbly checked when the copy reached Dana's mailbox. -->
|
||||
<div class={`${FLOAT} left-[78px] top-[330px] w-[330px] overflow-hidden`}>
|
||||
<div class="flex items-center gap-2.5 border-b border-slate-200 px-4 py-3">
|
||||
<span class="inline-flex size-7 items-center justify-center rounded-lg bg-emerald-50 text-emerald-600"><Glyph name="shieldCheck" size={14} /></span>
|
||||
<div class="min-w-0">
|
||||
<div class="text-[12.5px] font-medium text-slate-900">Delivery verified</div>
|
||||
<div class="text-[10.5px] text-slate-400">alex@northwind.dev → dana@helio.gen</div>
|
||||
</div>
|
||||
<span class="cv-ok ml-auto inline-flex h-5 items-center rounded-full border border-emerald-100 bg-emerald-50 px-2 text-[10px] font-semibold uppercase tracking-wide text-emerald-700">Counted</span>
|
||||
</div>
|
||||
<div class="px-4 py-2">
|
||||
{checks.map((c, i) => (
|
||||
<div class="flex h-8 items-center gap-2 text-[12px]" style={`--i:${i}`}>
|
||||
<span class="w-[92px] shrink-0 text-slate-500">{c.k}</span>
|
||||
<span class="cv-val inline-flex min-w-0 items-center gap-1.5 text-slate-900">
|
||||
{'chip' in c
|
||||
? <span class="inline-flex h-5 items-center gap-1 rounded border border-emerald-200 bg-emerald-50 px-1.5 text-[10.5px] font-medium text-emerald-700"><span class="size-1.5 rounded-full bg-emerald-500"></span>{c.v}</span>
|
||||
: <span class="truncate">{c.v}</span>}
|
||||
</span>
|
||||
<span class="relative ml-auto inline-flex size-4 shrink-0 items-center justify-center">
|
||||
<span class="cv-wait absolute size-3 rounded-full border-[1.5px] border-slate-200 border-t-sky-500"></span>
|
||||
<span class="cv-pass absolute text-emerald-600"><Glyph name="check" size={13} /></span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div class="border-t border-slate-100 px-4 py-2.5 text-[10.5px] leading-relaxed text-slate-400">The same token in any other inbox counts for nothing.</div>
|
||||
</div>
|
||||
</ScaledStage>
|
||||
|
||||
<style>
|
||||
/* One 11s loop: the reply lands, then each check resolves in turn.
|
||||
Base styles are the end state, which is what reduced motion shows. */
|
||||
.cv-wait { opacity: 0; }
|
||||
.cv-new { animation: cv-new 11s ease-out infinite; }
|
||||
@keyframes cv-new {
|
||||
0%, 3% { opacity: 0; transform: translateY(8px); }
|
||||
8%, 95% { opacity: 1; transform: none; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
.cv-wait { animation: cv-spin 0.8s linear infinite, cv-wait 11s linear infinite; animation-delay: 0s, calc(var(--i) * 0.5s); }
|
||||
.cv-pass, .cv-val { animation: cv-pass 11s linear infinite; animation-delay: calc(var(--i) * 0.5s); animation-fill-mode: both; }
|
||||
.cv-ok { animation: cv-ok 11s linear infinite; animation-fill-mode: both; }
|
||||
@keyframes cv-spin { to { transform: rotate(360deg); } }
|
||||
@keyframes cv-wait { 0%, 12% { opacity: 1; } 15%, 100% { opacity: 0; } }
|
||||
@keyframes cv-pass { 0%, 12% { opacity: 0; } 15%, 100% { opacity: 1; } }
|
||||
@keyframes cv-ok { 0%, 36% { opacity: 0; } 40%, 100% { opacity: 1; } }
|
||||
</style>
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
import Glyph from '../Glyph.astro';
|
||||
import { TAB } from '../../../lib/scene';
|
||||
|
||||
/**
|
||||
* The mailbox drawer header and tab bar (web/src/components/app/emails/
|
||||
* InboxDetails.tsx), with any tab active and the status chip the mailbox
|
||||
* really carries.
|
||||
*/
|
||||
type TabName = 'Overview' | 'Deliverability' | 'Analytics' | 'Warmup' | 'Sending' | 'Settings';
|
||||
interface Props {
|
||||
addr: string;
|
||||
provider: string;
|
||||
mark: 'google' | 'microsoft' | 'imap';
|
||||
active: TabName;
|
||||
status?: 'active' | 'paused';
|
||||
}
|
||||
const { addr, provider, mark, active, status = 'active' } = Astro.props;
|
||||
const initials = addr.slice(0, 2).toUpperCase();
|
||||
const tabs = [
|
||||
{ label: 'Overview', icon: 'gauge' },
|
||||
{ label: 'Deliverability', icon: 'mailCheck' },
|
||||
{ label: 'Analytics', icon: 'barChart' },
|
||||
{ label: 'Warmup', icon: 'flame' },
|
||||
{ label: 'Sending', icon: 'clock' },
|
||||
{ label: 'Settings', icon: 'settings' },
|
||||
] as const;
|
||||
const chip = status === 'active'
|
||||
? 'border-emerald-100 bg-emerald-50 text-emerald-700'
|
||||
: 'border-amber-100 bg-amber-50 text-amber-700';
|
||||
---
|
||||
<div class="h-14 px-5 flex items-center gap-3 border-b border-slate-200">
|
||||
<div class="relative size-8 shrink-0 rounded-lg bg-sky-50 text-sky-700 flex items-center justify-center text-[11px] font-semibold">
|
||||
{initials}
|
||||
<span class="absolute -right-1 -bottom-1 inline-flex size-4 items-center justify-center rounded border border-slate-200 bg-white ring-2 ring-white"><Glyph mark={mark} size={10} /></span>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-[13px] font-medium text-slate-900">{addr}</div>
|
||||
<div class="truncate text-[10.5px] text-slate-400">{provider}</div>
|
||||
</div>
|
||||
<span class={`inline-flex h-5 shrink-0 items-center rounded-full border px-2 text-[10px] font-semibold uppercase tracking-wide ${chip}`}>{status}</span>
|
||||
<span class="inline-flex size-7 items-center justify-center rounded-md text-slate-400"><Glyph name="x" size={16} /></span>
|
||||
</div>
|
||||
<div class="border-b border-slate-200">
|
||||
<div class="flex items-center gap-1 px-3 overflow-hidden">
|
||||
{tabs.map((t) => (
|
||||
<span class={`${TAB} shrink-0 ${t.label === active ? 'font-medium text-slate-900' : 'text-slate-500'}`}>
|
||||
<Glyph name={t.icon} size={14} />{t.label}
|
||||
{t.label === active && <span class="absolute bottom-0 left-1.5 right-1.5 h-0.5 rounded-full bg-sky-600"></span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
/**
|
||||
* A full-width hero scene: the same fixed-size, scaled-as-one-picture stage
|
||||
* as ScaledStage, with the photo loaded first and at the page's full width.
|
||||
* It carries ScaledStage's classes, so that component's script (on the same
|
||||
* page) keeps it fitted, pauses it off screen and plays its `enter-*` pieces;
|
||||
* the inline script only sets the scale before first paint.
|
||||
*/
|
||||
import Photo from '../../Photo.astro';
|
||||
|
||||
interface Props {
|
||||
w: number;
|
||||
h: number;
|
||||
photo: string;
|
||||
class?: string;
|
||||
}
|
||||
const { w, h, photo, class: className = '' } = Astro.props;
|
||||
---
|
||||
<div class={`stage-box group/stage relative w-full overflow-hidden rounded-[6px] bg-slate-100 ${className}`} style={`aspect-ratio:${w}/${h};`}>
|
||||
<Photo name={photo} priority sizes="(min-width: 1440px) 1360px, 100vw" class="absolute inset-0 h-full w-full object-cover select-none pointer-events-none" />
|
||||
<div class="stage absolute left-0 top-0 origin-top-left" style={`width:${w}px;height:${h}px;transform:scale(var(--k,1));`} data-w={w} aria-hidden="true">
|
||||
<slot />
|
||||
</div>
|
||||
<script is:inline>
|
||||
(function () {
|
||||
var box = document.currentScript.parentElement;
|
||||
var stage = box.querySelector(':scope > .stage');
|
||||
if (stage) stage.style.setProperty('--k', String(box.clientWidth / Number(stage.dataset.w)));
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
/**
|
||||
* Lucide icons the shared lib/lucide.ts does not carry, drawn the same way
|
||||
* as Glyph. Used by the warmup and deliverability page scenes.
|
||||
*/
|
||||
const PATHS = {
|
||||
arrowLeft: '<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>',
|
||||
arrowRight: '<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>',
|
||||
chevronRight: '<path d="m9 18 6-6-6-6"/>',
|
||||
copy: '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
|
||||
ban: '<circle cx="12" cy="12" r="10"/><path d="m4.9 4.9 14.2 14.2"/>',
|
||||
coffee: '<path d="M10 2v2"/><path d="M14 2v2"/><path d="M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1"/><path d="M6 2v2"/>',
|
||||
timer: '<line x1="10" x2="14" y1="2" y2="2"/><line x1="12" x2="15" y1="14" y2="11"/><circle cx="12" cy="14" r="8"/>',
|
||||
eyeOff: '<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"/><path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"/><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"/><path d="m2 2 20 20"/>',
|
||||
list: '<path d="M3 12h.01"/><path d="M3 18h.01"/><path d="M3 6h.01"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M8 6h13"/>',
|
||||
refresh: '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>',
|
||||
xCircle: '<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>',
|
||||
helpCircle: '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>',
|
||||
minus: '<path d="M5 12h14"/>',
|
||||
cursorClick: '<path d="M14 4.1 12 6"/><path d="m5.1 8-2.9-.8"/><path d="m6 12-1.9 2"/><path d="M7.2 2.2 8 5.1"/><path d="M9.037 9.69a.498.498 0 0 1 .653-.653l11 4.5a.5.5 0 0 1-.074.949l-4.349 1.041a1 1 0 0 0-.74.739l-1.04 4.35a.5.5 0 0 1-.95.074z"/>',
|
||||
thumbsUp: '<path d="M7 10v12"/><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z"/>',
|
||||
thumbsDown: '<path d="M17 14V2"/><path d="M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88Z"/>',
|
||||
zap: '<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/>',
|
||||
shieldAlert: '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="M12 8v4"/><path d="M12 16h.01"/>',
|
||||
key: '<path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4"/><path d="m21 2-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/>',
|
||||
folder: '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
|
||||
star: '<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"/>',
|
||||
file: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>',
|
||||
trash: '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>',
|
||||
users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
||||
bell: '<path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/>',
|
||||
square: '<rect width="12" height="12" x="6" y="6" rx="1"/>',
|
||||
undo: '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5a5.5 5.5 0 0 1-5.5 5.5H11"/>',
|
||||
paperclip: '<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
|
||||
shuffle: '<path d="m18 14 4 4-4 4"/><path d="m18 2 4 4-4 4"/><path d="M2 18h1.4c1.3 0 2.5-.6 3.3-1.7l6.1-8.6c.7-1.1 2-1.7 3.3-1.7H22"/><path d="M2 6h1.9c1.5 0 2.9.9 3.6 2.2"/><path d="M22 18h-5.9c-1.3 0-2.6-.7-3.3-1.8l-.5-.8"/>',
|
||||
hourglass: '<path d="M5 22h14"/><path d="M5 2h14"/><path d="M17 22v-4.172a2 2 0 0 0-.586-1.414L12 12l-4.414 4.414A2 2 0 0 0 7 17.828V22"/><path d="M7 2v4.172a2 2 0 0 0 .586 1.414L12 12l4.414-4.414A2 2 0 0 0 17 6.172V2"/>',
|
||||
} as const;
|
||||
|
||||
export type IcoName = keyof typeof PATHS;
|
||||
|
||||
interface Props {
|
||||
name: IcoName;
|
||||
size?: number;
|
||||
class?: string;
|
||||
}
|
||||
const { name, size = 14, class: className = '' } = Astro.props;
|
||||
---
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`shrink-0 ${className}`} set:html={PATHS[name]} />
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
import ScaledStage from '../../ScaledStage.astro';
|
||||
import DrawerHead from './DrawerHead.astro';
|
||||
import Glyph from '../Glyph.astro';
|
||||
import { WINDOW, FLOAT, EYEBROW } from '../../../lib/scene';
|
||||
|
||||
/**
|
||||
* The drawer's Deliverability tab (MailboxPlacementTab.tsx) for a mailbox on
|
||||
* watch: the inbox ring, its band, where warmup mail landed each day and the
|
||||
* rate per recipient provider. Over it, an Accounts row for a mailbox that
|
||||
* crossed into throttled, with the reason the pool wrote for it.
|
||||
*/
|
||||
// Inbox, other tabs, spam per day over the last 14 days.
|
||||
const days = [
|
||||
[6, 0, 0], [7, 1, 0], [6, 0, 0], [7, 0, 0], [6, 1, 0], [7, 0, 0], [8, 0, 0],
|
||||
[6, 0, 1], [6, 1, 1], [7, 0, 1], [5, 0, 2], [6, 1, 1], [6, 0, 1], [6, 1, 1],
|
||||
];
|
||||
const max = 9;
|
||||
const providers = [
|
||||
{ mark: 'google', name: 'Google', pct: 95, spam: '5%', n: 21 },
|
||||
{ mark: 'microsoft', name: 'Microsoft', pct: 74, spam: '26%', n: 19 },
|
||||
{ mark: 'yahoo', name: 'Yahoo & AOL', pct: 90, spam: '10%', n: 10 },
|
||||
] as const;
|
||||
const R = 36;
|
||||
const C = 2 * Math.PI * R;
|
||||
const tone = (p: number) => (p >= 90 ? 'text-emerald-600' : p >= 80 ? 'text-amber-600' : 'text-rose-600');
|
||||
---
|
||||
<ScaledStage w={760} h={570} photo="frost-blue">
|
||||
<div class={`${WINDOW} left-[160px] top-[36px] w-[600px] h-[560px]`}>
|
||||
<DrawerHead addr="lea@northwind.io" provider="Microsoft 365" mark="microsoft" active="Deliverability" />
|
||||
|
||||
<div class="flex items-center gap-4 border-b border-slate-200/60 px-5 py-4">
|
||||
<div class="relative size-[84px] shrink-0">
|
||||
<svg width="84" height="84" viewBox="0 0 84 84" class="-rotate-90">
|
||||
<circle cx="42" cy="42" r={R} stroke="#f1f5f9" stroke-width="7" fill="none" />
|
||||
<circle class="enter-draw" cx="42" cy="42" r={R} stroke="#f59e0b" stroke-width="7" fill="none" stroke-dasharray={C} stroke-dashoffset={C * 0.14} style={`--len:${C}; --d:150ms`} />
|
||||
</svg>
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span class="text-[19px] font-light leading-none text-slate-900">86%</span>
|
||||
<span class="mt-1 text-[8.5px] uppercase tracking-[0.14em] text-slate-400">inbox</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="inline-flex h-5 items-center gap-1 rounded border border-amber-200 bg-amber-50 px-1.5 text-[10px] font-medium text-amber-700"><span class="size-1.5 rounded-full bg-amber-500"></span>Watch</span>
|
||||
<span class="inline-flex h-5 items-center rounded border border-orange-200 bg-orange-50 px-1.5 text-[10px] font-medium text-orange-700">Pool standing: watch</span>
|
||||
</div>
|
||||
<p class="mt-2 text-[12.5px] leading-snug text-slate-700">43 of 50 warmup emails at Google, Microsoft and Yahoo reached the inbox over the last 7 days.</p>
|
||||
<p class="mt-1 font-mono text-[11px] tabular-nums text-slate-400">7 in spam · 3 in other tabs · 90%+ is healthy</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-b border-slate-200/60 px-5 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class={EYEBROW}>Where it landed each day</span>
|
||||
<span class="flex items-center gap-3 text-[10px] text-slate-400">
|
||||
<span class="inline-flex items-center gap-1.5"><span class="size-1.5 rounded-full bg-emerald-500"></span>Inbox</span>
|
||||
<span class="inline-flex items-center gap-1.5"><span class="size-1.5 rounded-full bg-violet-500"></span>Other tabs</span>
|
||||
<span class="inline-flex items-center gap-1.5"><span class="size-1.5 rounded-full bg-rose-500"></span>Spam</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-4 flex h-[92px] items-end gap-[7px]">
|
||||
{days.map(([inbox, tabs, spam], i) => (
|
||||
<div class="enter-grow-y flex flex-1 flex-col-reverse overflow-hidden rounded-t-[2px]" style={`height:${((inbox + tabs + spam) / max) * 100}%; --d:${250 + i * 40}ms`}>
|
||||
<div class="pw-dot pw-em" style={`flex:${inbox}`}></div>
|
||||
{tabs > 0 && <div class="pw-dot pw-vi" style={`flex:${tabs}`}></div>}
|
||||
{spam > 0 && <div class="pw-dot pw-ro" style={`flex:${spam}`}></div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div class="h-px bg-slate-200/80"></div>
|
||||
<div class="mt-1.5 flex justify-between font-mono text-[9.5px] text-slate-400"><span>Sep 26</span><span>Oct 9</span></div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 pt-4">
|
||||
<div class={EYEBROW}>By recipient provider</div>
|
||||
<div class="mt-2">
|
||||
{providers.map((p) => (
|
||||
<div class="flex h-9 items-center gap-3 text-[12px]">
|
||||
<Glyph mark={p.mark} size={14} />
|
||||
<span class="w-[84px] font-medium text-slate-900">{p.name}</span>
|
||||
<div class="flex h-1.5 flex-1 overflow-hidden rounded-full bg-slate-100">
|
||||
<div class="enter-grow-x h-full bg-emerald-500" style={`width:${p.pct}%; --d:700ms`}></div>
|
||||
<div class="h-full bg-rose-500" style={`width:${100 - p.pct}%`}></div>
|
||||
</div>
|
||||
<span class={`w-9 text-right font-mono text-[11px] tabular-nums ${tone(p.pct)}`}>{p.pct}%</span>
|
||||
<span class="w-14 text-right font-mono text-[11px] tabular-nums text-rose-600">{p.spam} spam</span>
|
||||
<span class="w-6 text-right font-mono text-[11px] tabular-nums text-slate-400">{p.n}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Another mailbox past 20%: an Accounts row, and the reason the pool wrote. -->
|
||||
<div class="enter-fade absolute left-[22px] top-[292px] w-[336px]" style="--d:1300ms">
|
||||
<div class={`${FLOAT.replace('absolute ', '')} relative overflow-hidden`}>
|
||||
<div class="flex h-12 items-center gap-2.5 border-b border-slate-100 px-4">
|
||||
<span class="inline-flex size-6 shrink-0 items-center justify-center rounded-full border border-slate-200 bg-white"><Glyph mark="microsoft" size={13} /></span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-[12.5px] font-medium leading-tight text-slate-900">dev@sunriselabs.io</div>
|
||||
<div class="truncate text-[11px] leading-tight text-slate-400"><span class="text-slate-500">Noah Grant</span> · Microsoft 365</div>
|
||||
</div>
|
||||
<span class="inline-flex h-5 shrink-0 items-center rounded border border-amber-200 bg-amber-50 px-1.5 text-[10px] font-medium text-amber-700">Throttled</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 border-b border-slate-100 px-4 py-2 text-[11px] text-slate-400">
|
||||
<span class="inline-flex items-center gap-1.5">Warmup <span class="inline-flex items-center gap-1.5 font-mono text-[12px] tabular-nums text-orange-600"><span class="campaign-grid"></span>6<span class="opacity-60">/13</span></span></span>
|
||||
<span class="inline-flex items-center gap-1.5">Inbox <span class="inline-flex items-center gap-1.5 font-mono text-[11.5px] font-medium text-rose-600"><span class="size-1.5 rounded-full bg-rose-500"></span>77%</span></span>
|
||||
<span class="inline-flex items-center gap-1.5">Health <span class="font-medium uppercase tracking-[0.08em] text-amber-600">At risk 71</span></span>
|
||||
</div>
|
||||
<p class="px-4 py-3 text-[11.5px] leading-relaxed text-slate-500">23% of warmup mail delivered at Google, Microsoft and Yahoo landed in spam over 7 days (48 delivered). Warmup and cold sending run at half volume with wider spacing until it is below 15%, then return to normal on their own.</p>
|
||||
</div>
|
||||
</div>
|
||||
</ScaledStage>
|
||||
|
||||
<style>
|
||||
.pw-dot { background-size: 2px 2px; }
|
||||
.pw-em { background-color: rgb(16 185 129 / 0.55); background-image: radial-gradient(circle, rgb(16 185 129) 0.8px, transparent 1px); }
|
||||
.pw-vi { background-color: rgb(139 92 246 / 0.55); background-image: radial-gradient(circle, rgb(139 92 246) 0.8px, transparent 1px); }
|
||||
.pw-ro { background-color: rgb(244 63 94 / 0.6); background-image: radial-gradient(circle, rgb(244 63 94) 0.8px, transparent 1px); }
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
import ScaledStage from '../../ScaledStage.astro';
|
||||
import DrawerHead from './DrawerHead.astro';
|
||||
import Glyph from '../Glyph.astro';
|
||||
import Ico from './Ico.astro';
|
||||
import { WINDOW, FLOAT, EYEBROW } from '../../../lib/scene';
|
||||
|
||||
/**
|
||||
* A quarantined mailbox on the drawer's Warmup tab: the banner
|
||||
* (WarmupBanBanner) with its reason, dates and appeal, then the pool standing
|
||||
* the Warmup reputation block shows. Beside it, the bands a standing moves
|
||||
* through, with this one lit.
|
||||
*/
|
||||
const bands = [
|
||||
{ label: 'Healthy', dot: 'bg-emerald-500', when: 'Nothing over a line', does: 'Full volume' },
|
||||
{ label: 'Watch', dot: 'bg-amber-400', when: 'Spam 10% · complaints 0.03%', does: 'Slowed a little' },
|
||||
{ label: 'Throttled', dot: 'bg-amber-500', when: 'Spam 20%', does: 'Half volume, lifts itself' },
|
||||
{ label: 'Quarantined', dot: 'bg-rose-500', when: 'Complaints 0.10% · bounces 5%', does: 'Out 7 days, cold paused', current: true },
|
||||
{ label: 'Blocked', dot: 'bg-rose-700', when: 'Complaints 0.30% · bounces 10%', does: 'Out 30 days' },
|
||||
];
|
||||
---
|
||||
<ScaledStage w={760} h={570} photo="sheer-lavender">
|
||||
<div class={`${WINDOW} left-[36px] top-[36px] w-[470px] h-[560px]`}>
|
||||
<DrawerHead addr="ana@brightco.io" provider="Google Workspace" mark="google" active="Warmup" />
|
||||
<div class="divide-y divide-slate-200/60">
|
||||
<div class="px-5 py-4">
|
||||
<div class="rounded-md border border-rose-200 bg-rose-50 p-3.5">
|
||||
<div class="flex items-start gap-2.5">
|
||||
<span class="inline-flex size-7 shrink-0 items-center justify-center rounded-md bg-rose-100 text-rose-600"><Ico name="ban" size={16} /></span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[12.5px] font-semibold text-rose-900">Blocked from warmup</span>
|
||||
<span class="inline-flex h-5 items-center rounded-full border border-rose-200 bg-white/60 px-2 text-[10px] font-semibold uppercase tracking-wide text-rose-700">Quarantined</span>
|
||||
</div>
|
||||
<p class="mt-1 text-[11.5px] leading-relaxed text-rose-800/90">bounce rate 6.2% exceeded quarantine threshold</p>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[10.5px] text-rose-700/80">
|
||||
<span class="inline-flex items-center gap-1"><Glyph name="clock" size={12} />Blocked Oct 9</span>
|
||||
<span class="inline-flex items-center gap-1"><Glyph name="calendar" size={12} />Until Oct 16</span>
|
||||
</div>
|
||||
<span class="mt-3 h-8 px-3 rounded-md bg-rose-600 text-white text-[12px] font-medium inline-flex items-center gap-1.5"><Ico name="shieldAlert" size={14} />Appeal this block</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class={EYEBROW}>Warmup reputation</span>
|
||||
<span class="text-[10.5px] text-slate-400">Premium pool</span>
|
||||
</div>
|
||||
<span class="inline-flex items-center gap-1 text-[11px] font-medium text-rose-600"><Glyph name="shieldCheck" size={14} />Quarantined</span>
|
||||
</div>
|
||||
<p class="mt-1.5 text-[11.5px] leading-relaxed text-slate-500">bounce rate 6.2% exceeded quarantine threshold</p>
|
||||
<p class="mt-1 text-[11px] text-rose-600">Paused from the pool until Oct 16.</p>
|
||||
</div>
|
||||
|
||||
<div class="px-5 py-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class={EYEBROW}>Domain authentication</div>
|
||||
<p class="mt-1 text-[11px] leading-relaxed text-slate-400">SPF, DKIM and DMARC records for brightco.io.</p>
|
||||
</div>
|
||||
<span class="h-8 px-3 rounded-md border border-slate-200 inline-flex shrink-0 items-center gap-1.5 text-[12px] font-medium text-slate-700"><Ico name="refresh" size={14} />Check</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 py-4">
|
||||
<div class={EYEBROW}>Ramp configuration</div>
|
||||
<div class="mt-3 grid grid-cols-2 gap-3">
|
||||
<div><div class="mb-1 text-[12px] font-medium text-slate-700">Starting volume</div><div class="flex h-9 items-center justify-end gap-2 rounded-md border border-slate-200 px-2.5 text-[12.5px] tabular-nums text-slate-900">10 <span class="text-[11px] text-slate-400">emails / day</span></div></div>
|
||||
<div><div class="mb-1 text-[12px] font-medium text-slate-700">Maximum volume</div><div class="flex h-9 items-center justify-end gap-2 rounded-md border border-slate-200 px-2.5 text-[12.5px] tabular-nums text-slate-900">40 <span class="text-[11px] text-slate-400">emails / day</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The bands a mailbox's pool standing moves through. -->
|
||||
<div class={`${FLOAT} left-[452px] top-[150px] w-[284px] overflow-hidden`}>
|
||||
<div class="border-b border-slate-200 px-4 py-3">
|
||||
<div class={EYEBROW}>Pool standing</div>
|
||||
<div class="mt-1 text-[12px] text-slate-500">The worst signal sets the band.</div>
|
||||
</div>
|
||||
<div class="py-1.5">
|
||||
{bands.map((b, i) => (
|
||||
<div class={`relative mx-1.5 rounded-md px-2.5 py-2 ${b.current ? 'ps-cur bg-rose-50 ring-1 ring-rose-200' : ''}`} style={`--i:${i}`}>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class={`size-2 rounded-full ${b.dot}`}></span>
|
||||
<span class={`text-[12.5px] font-medium ${b.current ? 'text-rose-900' : 'text-slate-900'}`}>{b.label}</span>
|
||||
<span class={`ml-auto text-[11px] ${b.current ? 'text-rose-700' : 'text-slate-500'}`}>{b.does}</span>
|
||||
</div>
|
||||
<div class={`mt-0.5 pl-4 font-mono text-[10.5px] ${b.current ? 'text-rose-700/80' : 'text-slate-400'}`}>{b.when}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ScaledStage>
|
||||
|
||||
<style>
|
||||
/* The current band is picked out once the card is on screen. */
|
||||
.ps-cur { animation: ps-cur 6s ease-in-out infinite; }
|
||||
@keyframes ps-cur {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgb(244 63 94 / 0); }
|
||||
50% { box-shadow: 0 0 0 4px rgb(244 63 94 / 0.12); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,131 @@
|
||||
---
|
||||
import ScaledStage from '../../ScaledStage.astro';
|
||||
import Glyph from '../Glyph.astro';
|
||||
import Ico from './Ico.astro';
|
||||
import { WINDOW, FLOAT, EYEBROW, SECTIONBAR } from '../../../lib/scene';
|
||||
|
||||
/**
|
||||
* Who one premium mailbox can write to today, laid out the way the partner
|
||||
* draw in internal/repository/pg_warmup.go reads it: its own tier, proven
|
||||
* free mailboxes from other workspaces, each inbox's daily receive cap and
|
||||
* how far behind it is on what it sent. The card shows the other direction:
|
||||
* which paying inboxes a free mailbox may write back to.
|
||||
*/
|
||||
type Row = {
|
||||
addr: string; host: string; mark: 'google' | 'microsoft' | 'imap' | 'yahoo';
|
||||
pool: 'premium' | 'free'; origin: string; got: number; cap: number;
|
||||
owed?: number; weight?: string; out?: string; note?: string;
|
||||
};
|
||||
const rows: Row[] = [
|
||||
{ addr: 'lena@sprucebio.com', host: 'Google Workspace', mark: 'google', pool: 'premium', origin: 'Own tier', got: 12, cap: 36, owed: 14, weight: '2.6' },
|
||||
{ addr: 'dana@helio.gen', host: 'Google Workspace', mark: 'google', pool: 'premium', origin: 'Own tier', got: 18, cap: 52, owed: 9, weight: '1.9' },
|
||||
{ addr: 'kai@orbitlabs.io', host: 'Microsoft 365', mark: 'microsoft', pool: 'free', origin: 'Borrowed', got: 9, cap: 30, owed: 6, weight: '1.6', note: 'In the pool 41 days, healthy' },
|
||||
{ addr: 'max@quarry.co', host: 'Microsoft 365', mark: 'microsoft', pool: 'premium', origin: 'Own tier', got: 31, cap: 60, owed: 0, weight: '1.0' },
|
||||
{ addr: 'ines@verdant.co', host: 'Yahoo Mail', mark: 'yahoo', pool: 'free', origin: 'Borrowed', got: 14, cap: 28, owed: 0, weight: '1.0', note: 'In the pool 12 days, healthy' },
|
||||
{ addr: 'ravi@stratech.io', host: 'Microsoft 365', mark: 'microsoft', pool: 'premium', origin: 'Own tier', got: 60, cap: 60, out: 'Full for today' },
|
||||
{ addr: 'tom@fernway.io', host: 'Google Workspace', mark: 'google', pool: 'free', origin: 'Not offered', got: 0, cap: 24, out: 'On watch' },
|
||||
];
|
||||
const th = 'px-3 py-2 text-left text-[10px] font-medium uppercase tracking-[0.14em] text-slate-400';
|
||||
---
|
||||
<ScaledStage w={760} h={570} photo="lake-mist">
|
||||
<div class={`${WINDOW} left-[24px] top-[128px] w-[736px] h-[460px]`}>
|
||||
<div class="h-12 px-5 border-b border-slate-200 flex items-center gap-3">
|
||||
<span class={EYEBROW}>Warmup partners</span>
|
||||
<span class="h-4 w-px bg-slate-200"></span>
|
||||
<span class="text-[12.5px] text-slate-600">alex@northwind.dev</span>
|
||||
<span class="inline-flex h-5 items-center rounded-full border border-sky-200 bg-sky-50 px-2 text-[10px] font-semibold uppercase tracking-wide text-sky-700">Premium pool</span>
|
||||
</div>
|
||||
<div class={SECTIONBAR}>
|
||||
<span class={EYEBROW}>Candidates</span>
|
||||
<span class="font-mono text-[10.5px] text-slate-400 tabular-nums">7</span>
|
||||
<span class="text-[11.5px] text-slate-500">· 5 offered today · drawn by weight</span>
|
||||
</div>
|
||||
<table class="w-full table-fixed">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-200">
|
||||
<th class={`${th} pl-5`}>Partner</th>
|
||||
<th class={`${th} w-[128px]`}>Pool</th>
|
||||
<th class={`${th} w-[150px]`}>Received today</th>
|
||||
<th class={`${th} w-[92px]`}>Owed</th>
|
||||
<th class={`${th} w-[76px] pr-5 text-right`}>Weight</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => {
|
||||
const off = !!r.out;
|
||||
const pct = Math.round((r.got / r.cap) * 100);
|
||||
return (
|
||||
<tr class={`h-[46px] border-b border-slate-200/60 ${off ? 'bg-slate-50/70' : ''}`}>
|
||||
<td class="pl-5 pr-3">
|
||||
<div class={`flex items-center gap-2.5 min-w-0 ${off ? 'opacity-55' : ''}`}>
|
||||
<span class="inline-flex size-6 shrink-0 items-center justify-center rounded-full border border-slate-200 bg-white"><Glyph mark={r.mark} size={13} /></span>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-[12.5px] font-medium leading-tight text-slate-900">{r.addr}</div>
|
||||
<div class="mt-0.5 truncate text-[11px] leading-tight text-slate-400">{r.note ?? r.host}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3">
|
||||
<span class={`inline-flex h-5 items-center gap-1 rounded border px-1.5 text-[10px] font-medium ${r.pool === 'premium' ? 'border-sky-200 bg-sky-50 text-sky-700' : 'border-slate-200 bg-white text-slate-600'}`}>
|
||||
<span class={`size-1.5 rounded-full ${r.pool === 'premium' ? 'bg-sky-500' : 'bg-slate-400'}`}></span>
|
||||
{r.pool === 'premium' ? 'Premium' : 'Free'}
|
||||
</span>
|
||||
<span class={`ml-1 text-[10.5px] ${r.origin === 'Borrowed' ? 'text-violet-600' : 'text-slate-400'}`}>{r.origin === 'Not offered' ? '' : r.origin}</span>
|
||||
</td>
|
||||
<td class="px-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-1.5 flex-1 overflow-hidden rounded-full bg-slate-100">
|
||||
<div class={`enter-grow-x h-full rounded-full ${pct >= 100 ? 'bg-rose-400' : 'bg-sky-400'}`} style={`width:${pct}%; --d:${300 + i * 60}ms`}></div>
|
||||
</div>
|
||||
<span class="w-11 text-right font-mono text-[11px] tabular-nums text-slate-500">{r.got}/{r.cap}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3">
|
||||
{off ? (
|
||||
<span class={`text-[11px] font-medium ${r.out === 'On watch' ? 'text-amber-600' : 'text-rose-600'}`}>{r.out}</span>
|
||||
) : r.owed ? (
|
||||
<span class="font-mono text-[11.5px] tabular-nums text-emerald-600">+{r.owed} behind</span>
|
||||
) : (
|
||||
<span class="font-mono text-[11.5px] text-slate-400">even</span>
|
||||
)}
|
||||
</td>
|
||||
<td class="pr-5 text-right">
|
||||
{off ? <span class="text-slate-300">—</span> : (
|
||||
<span class={`inline-flex h-5 items-center rounded px-1.5 font-mono text-[11px] tabular-nums ${Number(r.weight) > 1 ? 'bg-emerald-50 text-emerald-700' : 'text-slate-500'}`}>×{r.weight}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- The other direction: a free mailbox only writes back to paying inboxes that wrote first. -->
|
||||
<div class={`${FLOAT} left-[392px] top-[22px] w-[344px] overflow-hidden`}>
|
||||
<div class="flex items-center gap-2.5 border-b border-slate-200 px-4 py-3">
|
||||
<span class="inline-flex size-6 shrink-0 items-center justify-center rounded-full border border-slate-200 bg-white"><Glyph mark="microsoft" size={13} /></span>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-[12.5px] font-medium text-slate-900">kai@orbitlabs.io</div>
|
||||
<div class="text-[10.5px] text-slate-400">Free pool · may write back to</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-4 py-2">
|
||||
<div class="flex h-8 items-center gap-2 text-[12px]">
|
||||
<span class="pk-ok inline-flex size-4 items-center justify-center rounded-full bg-emerald-50 text-emerald-600"><Glyph name="check" size={10} /></span>
|
||||
<span class="text-slate-900">alex@northwind.dev</span>
|
||||
<span class="ml-auto font-mono text-[10.5px] text-slate-400">wrote 2 days ago</span>
|
||||
</div>
|
||||
<div class="flex h-8 items-center gap-2 text-[12px]">
|
||||
<span class="inline-flex size-4 items-center justify-center rounded-full bg-slate-100 text-slate-400"><Ico name="minus" size={10} /></span>
|
||||
<span class="text-slate-400">sam@northwind.io</span>
|
||||
<span class="ml-auto font-mono text-[10.5px] text-slate-400">no mail in 14 days</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScaledStage>
|
||||
|
||||
<style>
|
||||
.pk-ok { animation: pk-ok 4s ease-in-out infinite; }
|
||||
@keyframes pk-ok { 0%, 100% { box-shadow: 0 0 0 0 rgb(16 185 129 / 0); } 50% { box-shadow: 0 0 0 4px rgb(16 185 129 / 0.15); } }
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
import ScaledStage from '../../ScaledStage.astro';
|
||||
import DrawerHead from './DrawerHead.astro';
|
||||
import Glyph from '../Glyph.astro';
|
||||
import { WINDOW, FLOAT, EYEBROW } from '../../../lib/scene';
|
||||
|
||||
/**
|
||||
* The mailbox drawer's Analytics tab (AnalyticsTab in InboxDetails.tsx):
|
||||
* exchange totals, then sent against target per day as the dashboard's
|
||||
* dithered bars over solid target bars. Day 9 shows the ramp holding after
|
||||
* a spam placement, with the notice the Warmup tab shows for it.
|
||||
*/
|
||||
// Default ramp (10, +1 a day). A spam landing on day 9 cuts the target by a
|
||||
// quarter for two days and pauses the increase for three, without catch-up.
|
||||
const DAYS = 25;
|
||||
const target = (d: number) => {
|
||||
if (d === 9 || d === 10) return 13;
|
||||
if (d === 11) return 17;
|
||||
return d < 9 ? 10 + (d - 1) : 10 + (d - 1) - 3;
|
||||
};
|
||||
const series = Array.from({ length: DAYS }, (_, i) => {
|
||||
const d = i + 1;
|
||||
const t = target(d);
|
||||
const sent = d === DAYS ? 22 : t - (d % 6 === 4 ? 1 : 0);
|
||||
return { d, t, sent };
|
||||
});
|
||||
const totalSent = series.reduce((n, s) => n + s.sent, 0);
|
||||
const received = Math.round(totalSent * 0.94);
|
||||
const replies = Math.round(totalSent * 0.29);
|
||||
const max = 32;
|
||||
const H = 124;
|
||||
const stats = [
|
||||
{ label: 'Total sent', value: totalSent, sub: `${(totalSent / DAYS).toFixed(1)}/active day` },
|
||||
{ label: 'Received', value: received, sub: '94% of what it sent', accent: true },
|
||||
{ label: 'Replies', value: replies, sub: `${((replies / totalSent) * 100).toFixed(1)}% reply rate` },
|
||||
{ label: 'Target met', value: '98%', sub: 'of planned volume' },
|
||||
];
|
||||
---
|
||||
<ScaledStage w={760} h={570} photo="sheer-sky">
|
||||
<div class={`${WINDOW} left-[120px] top-[40px] w-[640px] h-[560px]`}>
|
||||
<DrawerHead addr="kai@northwind.dev" provider="Google Workspace" mark="google" active="Analytics" />
|
||||
|
||||
<div class="grid grid-cols-2 divide-x divide-y divide-slate-200/60 border-b border-slate-200/60">
|
||||
{stats.map((s) => (
|
||||
<div class="px-4 py-3.5">
|
||||
<div class={EYEBROW}>{s.label}</div>
|
||||
<div class={`mt-1 text-[24px] font-light leading-none tabular-nums ${s.accent ? 'text-sky-600' : 'text-slate-900'}`}>{s.value}</div>
|
||||
<div class="mt-1.5 truncate font-mono text-[10.5px] text-slate-400">{s.sub}</div>
|
||||
</div>
|
||||
))}
|
||||
<div class="col-span-2 px-4 py-3.5">
|
||||
<div class={EYEBROW}>Days active</div>
|
||||
<div class="mt-1 text-[24px] font-light leading-none tabular-nums text-slate-900">{DAYS}</div>
|
||||
<div class="mt-1.5 truncate font-mono text-[10.5px] text-slate-400">Sep 15 → Oct 9</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 py-4">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<span class={EYEBROW}>Daily volume</span>
|
||||
<span class="flex items-center gap-3 text-[10px] text-slate-400">
|
||||
<span class="inline-flex items-center gap-1"><span class="size-2 rounded-sm bg-sky-500"></span>Sent</span>
|
||||
<span class="inline-flex items-center gap-1"><span class="size-2 rounded-sm bg-slate-200"></span>Target</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-end gap-[3px]" style={`height:${H}px`}>
|
||||
{series.map((s, i) => (
|
||||
<div class="relative h-full flex-1">
|
||||
<div class="enter-grow-y absolute inset-x-0 bottom-0 bg-slate-100" style={`height:${(s.t / max) * 100}%; --d:${i * 30}ms`}></div>
|
||||
<div
|
||||
class={`enter-grow-y absolute inset-x-0 bottom-0 ${s.d === DAYS || s.d === 9 ? 'rp-hot' : 'rp-dith'}`}
|
||||
style={`height:${(s.sent / max) * 100}%; --d:${200 + i * 30}ms`}
|
||||
></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div class="h-px bg-slate-200/80"></div>
|
||||
<div class="mt-1.5 flex justify-between font-mono text-[9.5px] text-slate-400"><span>Sep 15</span><span>Today</span></div>
|
||||
<p class="mt-2 font-mono text-[11px] tabular-nums text-slate-500">Sep 23: 13 sent / 13 target · 12 received · 4 replies</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The hold behind the dip, as the Warmup tab words it. -->
|
||||
<div class={`${FLOAT} left-[22px] top-[300px] w-[268px] p-3`}>
|
||||
<div class="rounded-md border border-amber-200 bg-amber-50 px-3 py-2.5 flex items-start gap-2">
|
||||
<Glyph name="alert" size={14} class="mt-px text-amber-600" />
|
||||
<div class="min-w-0">
|
||||
<p class="text-[12.5px] font-medium text-amber-900">Warmup volume held back</p>
|
||||
<p class="mt-0.5 text-[11.5px] leading-relaxed text-amber-800/90">1 warmup email landed in spam in the last 48 hours out of 17 sent. Today's target is cut by a quarter and the daily increase is paused. It resumes on its own if nothing else lands in spam.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center gap-1.5 px-1 font-mono text-[10.5px] text-slate-400"><span class="size-1.5 rounded-full bg-amber-500"></span>Sep 23 · day 9 of the ramp</div>
|
||||
</div>
|
||||
</ScaledStage>
|
||||
|
||||
<style>
|
||||
/* The dashboard's dithered bars: a solid cap, then dots thinning to the baseline. */
|
||||
.rp-dith {
|
||||
background:
|
||||
linear-gradient(#0ea5e9, #0ea5e9) top / 100% 2px no-repeat,
|
||||
radial-gradient(circle, rgb(14 165 233 / 0.95) 0.8px, transparent 1px) 0 0 / 2px 2px;
|
||||
-webkit-mask-image: linear-gradient(to bottom, #000 0%, #000 20%, rgb(0 0 0 / 0.35) 100%);
|
||||
mask-image: linear-gradient(to bottom, #000 0%, #000 20%, rgb(0 0 0 / 0.35) 100%);
|
||||
}
|
||||
.rp-hot { background: #0ea5e9; }
|
||||
</style>
|
||||
@@ -0,0 +1,298 @@
|
||||
---
|
||||
import HeroStage from './HeroStage.astro';
|
||||
import DrawerHead from './DrawerHead.astro';
|
||||
import Glyph from '../Glyph.astro';
|
||||
import Ico from './Ico.astro';
|
||||
import { WINDOW, FLOAT, EYEBROW, TOPBAR, SECTIONBAR, ACTION_GHOST, ACTION_PRIMARY, STAT, STAT_VALUE, STAT_SUB } from '../../../lib/scene';
|
||||
|
||||
/**
|
||||
* The Accounts page (web/src/app/app/emails/page.tsx) with its Warmup column
|
||||
* counting live, and the mailbox drawer (InboxDetails.tsx) open on the
|
||||
* Warmup tab: lifecycle, today's progress, pool standing, ramp settings and
|
||||
* warmup hours. Warmup activity streams in over the bottom of the list.
|
||||
*/
|
||||
type Status = 'active' | 'warming' | 'resting';
|
||||
type Row = {
|
||||
addr: string; name: string; provider: string; mark: 'google' | 'microsoft' | 'imap';
|
||||
status: Status; sent?: number; warm: [number, number] | 'Health-check' | 'Paused';
|
||||
tick?: number; inbox?: number; collecting?: string; health: [string, number]; selected?: boolean;
|
||||
};
|
||||
const rows: Row[] = [
|
||||
{ addr: 'alex@northwind.dev', name: 'Alex Kim', provider: 'Google Workspace', mark: 'google', status: 'warming', warm: [19, 27], inbox: 97, health: ['healthy', 97], selected: true },
|
||||
{ addr: 'sam@northwind.io', name: 'Sam Ortiz', provider: 'Microsoft 365', mark: 'microsoft', status: 'active', sent: 31, warm: [4, 5], inbox: 95, health: ['healthy', 93] },
|
||||
{ addr: 'mia@northwind.dev', name: 'Mia Chen', provider: 'Google Workspace', mark: 'google', status: 'warming', warm: [14, 23], tick: 3100, inbox: 96, health: ['healthy', 96] },
|
||||
{ addr: 'ops@northwind.co', name: 'Ops team', provider: 'SMTP / IMAP', mark: 'imap', status: 'active', sent: 18, warm: 'Health-check', inbox: 92, health: ['healthy', 90] },
|
||||
{ addr: 'jon@northwind.dev', name: 'Jon Reyes', provider: 'Google Workspace', mark: 'google', status: 'warming', warm: [8, 12], tick: 4300, collecting: '7/20', health: ['healthy', 100] },
|
||||
{ addr: 'lea@northwind.io', name: 'Lea Novak', provider: 'Microsoft 365', mark: 'microsoft', status: 'warming', warm: [9, 18], tick: 5200, inbox: 86, health: ['risk', 78] },
|
||||
{ addr: 'hello@helio.gen', name: 'Lena Fischer', provider: 'Microsoft 365', mark: 'microsoft', status: 'active', sent: 22, warm: [5, 5], inbox: 94, health: ['healthy', 94] },
|
||||
{ addr: 'kai@northwind.dev', name: 'Kai Brandt', provider: 'Google Workspace', mark: 'google', status: 'warming', warm: [22, 31], tick: 2700, inbox: 98, health: ['healthy', 98] },
|
||||
{ addr: 'sales@brightco.io', name: 'Ana Silva', provider: 'Google Workspace', mark: 'google', status: 'active', sent: 27, warm: [3, 5], inbox: 96, health: ['healthy', 96] },
|
||||
{ addr: 'team@coastline.io', name: 'Sofia Ruiz', provider: 'SMTP / IMAP', mark: 'imap', status: 'resting', warm: 'Paused', inbox: 90, health: ['healthy', 90] },
|
||||
{ addr: 'growth@acme.co', name: 'Maya Okonkwo', provider: 'Google Workspace', mark: 'google', status: 'active', sent: 25, warm: [4, 5], inbox: 97, health: ['healthy', 96] },
|
||||
];
|
||||
const stats = [
|
||||
{ label: 'Total', value: '12', sub: 'connected' },
|
||||
{ label: 'Healthy', value: '10', sub: 'sending now', pulse: true },
|
||||
{ label: 'Warming', value: '6', sub: 'ramping up' },
|
||||
{ label: 'Needs attention', value: '1', sub: 'paused or failing', last: true },
|
||||
];
|
||||
const feed = [
|
||||
{ icon: 'send', tone: 'text-sky-600 bg-sky-50', text: 'Sent to dana@helio.gen', sub: 'Google Workspace · 2:41 PM' },
|
||||
{ icon: 'inbox', tone: 'text-emerald-600 bg-emerald-50', text: 'Arrived in the inbox', sub: 'from ravi@stratech.io · Microsoft 365' },
|
||||
{ icon: 'reply', tone: 'text-violet-600 bg-violet-50', text: 'Replied to lena@sprucebio.com', sub: 'Thread of 3 · 2:26 PM' },
|
||||
{ icon: 'send', tone: 'text-sky-600 bg-sky-50', text: 'Sent to max@quarry.co', sub: 'Microsoft 365 · 2:09 PM' },
|
||||
{ icon: 'inbox', tone: 'text-emerald-600 bg-emerald-50', text: 'Arrived in the inbox', sub: 'from kai@orbitlabs.io · Google Workspace' },
|
||||
] as const;
|
||||
const ramp = [
|
||||
{ label: 'Starting volume', value: '10', suffix: 'emails / day', hint: 'Emails per day when warmup begins.' },
|
||||
{ label: 'Daily increase', value: '1', suffix: '+ / day', hint: 'Added each day as reputation builds.' },
|
||||
{ label: 'Maximum volume', value: '40', suffix: 'emails / day', hint: 'The ceiling the ramp grows toward.' },
|
||||
{ label: 'Reply rate', value: '30', suffix: '%', hint: 'Share of warmup mail that gets a reply.' },
|
||||
];
|
||||
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
const th = 'px-3 py-2 text-[10px] font-medium text-slate-400 uppercase tracking-[0.14em] text-left';
|
||||
const healthTone = (h: string) => h === 'healthy'
|
||||
? { dot: 'bg-emerald-500', text: 'text-emerald-700', label: 'Healthy' }
|
||||
: { dot: 'bg-amber-500', text: 'text-amber-600', label: 'At risk' };
|
||||
---
|
||||
<HeroStage w={1240} h={720} photo="paint-teal">
|
||||
<!-- Accounts -->
|
||||
<div class={`${WINDOW} left-[40px] top-[44px] w-[820px] h-[700px]`}>
|
||||
<div class={TOPBAR}>
|
||||
<span class={EYEBROW}>Accounts</span>
|
||||
<span class="h-4 w-px bg-slate-200"></span>
|
||||
<span class="text-[12.5px] text-slate-600">12 mailboxes</span>
|
||||
<div class="ml-auto flex items-center gap-1.5 pr-[170px]">
|
||||
<span class={ACTION_GHOST}><Glyph name="globe" size={12} />Sending domains</span>
|
||||
<span class={ACTION_PRIMARY}><Glyph name="plus" size={12} />Add account</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 border-b border-slate-200">
|
||||
{stats.map((s) => (
|
||||
<div class={s.last ? 'px-5 py-4' : STAT}>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class={EYEBROW}>{s.label}</span>
|
||||
{s.pulse && <span class="size-1.5 rounded-full bg-sky-500 animate-pulse"></span>}
|
||||
</div>
|
||||
<div class={STAT_VALUE}>{s.value}</div>
|
||||
<div class={STAT_SUB}>{s.sub}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div class={SECTIONBAR}>
|
||||
<span class={EYEBROW}>Mailboxes</span>
|
||||
<span class="font-mono text-[10.5px] text-slate-400 tabular-nums">12</span>
|
||||
<div class="ml-auto flex items-center gap-1.5 pr-[170px]">
|
||||
<div class="h-7 w-48 pl-2 pr-1 rounded-md border border-slate-200 bg-white flex items-center gap-1.5">
|
||||
<Glyph name="search" size={13} class="text-slate-400" />
|
||||
<span class="text-[12.5px] text-slate-400">Search by email…</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<table class="w-full table-fixed">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-200">
|
||||
<th class="pl-5 pr-2 py-2 w-11"><span class="block size-3.5 rounded-[4px] border border-slate-300"></span></th>
|
||||
<th class={th}>Mailbox</th>
|
||||
<th class={`${th} w-[106px]`}>Status</th>
|
||||
<th class={`${th} w-[92px]`}>Sent today</th>
|
||||
<th class={`${th} w-[112px]`}>Warmup</th>
|
||||
<th class={`${th} w-[84px]`}>Inbox</th>
|
||||
<th class={`${th} w-[126px]`}>Health</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => {
|
||||
const h = healthTone(r.health[0]);
|
||||
return (
|
||||
<tr class={`h-11 border-b border-slate-200/60 ${r.selected ? 'bg-sky-50/70' : ''}`}>
|
||||
<td class="pl-5 pr-2">
|
||||
{r.selected
|
||||
? <span class="flex size-3.5 items-center justify-center rounded-[4px] bg-sky-600 text-white"><Glyph name="check" size={10} /></span>
|
||||
: <span class="block size-3.5 rounded-[4px] border border-slate-300"></span>}
|
||||
</td>
|
||||
<td class="px-3">
|
||||
<div class="flex items-center gap-2.5 min-w-0">
|
||||
<span class="inline-flex size-6 shrink-0 items-center justify-center rounded-full border border-slate-200 bg-white">
|
||||
<Glyph mark={r.mark} size={r.mark === 'imap' ? 13 : 14} />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-[12.5px] font-medium leading-tight text-slate-900">{r.addr}</div>
|
||||
<div class="mt-0.5 truncate text-[11px] leading-tight text-slate-400"><span class="text-slate-500">{r.name}</span> · {r.provider}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3">
|
||||
{r.status === 'active' && (
|
||||
<span class="inline-flex items-center gap-1.5 text-[10.5px] font-medium uppercase tracking-[0.08em] text-sky-700">
|
||||
<span class="inline-flex items-center gap-0.5"><Glyph name="send" size={12} class="text-sky-600" /><Glyph name="flame" size={12} class="text-orange-500" /></span>
|
||||
<span class="text-shimmer">Active</span>
|
||||
</span>
|
||||
)}
|
||||
{r.status === 'warming' && (
|
||||
<span class="inline-flex items-center gap-1.5 text-[10.5px] font-medium uppercase tracking-[0.08em] text-orange-600">
|
||||
<Glyph name="flame" size={12} class="text-orange-500 flame-flicker" /><span class="text-shimmer">Warming</span>
|
||||
</span>
|
||||
)}
|
||||
{r.status === 'resting' && (
|
||||
<span class="inline-flex items-center gap-1.5 text-[10.5px] font-medium uppercase tracking-[0.08em] text-violet-600">
|
||||
<Glyph name="moon" size={12} class="text-violet-500" />Resting
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td class="px-3">
|
||||
{r.sent != null ? (
|
||||
<span class="inline-flex items-center gap-1.5 font-mono text-[12px] tabular-nums text-sky-700">
|
||||
<Glyph name="send" size={12} class="text-sky-500" />
|
||||
<span>{r.sent}<span class="text-slate-400">/50</span></span>
|
||||
</span>
|
||||
) : <span class="text-slate-300">—</span>}
|
||||
</td>
|
||||
<td class="px-3">
|
||||
{Array.isArray(r.warm) ? (
|
||||
<span class="inline-flex items-center gap-1.5 font-mono text-[12px] tabular-nums text-orange-600">
|
||||
<span class="campaign-grid shrink-0"></span>
|
||||
<span>{r.tick ? <span data-tick={`${r.warm[0]},${r.warm[1] - 1},${r.tick}`}>{r.warm[0]}</span> : r.warm[0]}<span class="opacity-60">/{r.warm[1]}</span></span>
|
||||
</span>
|
||||
) : r.warm === 'Paused' ? (
|
||||
<span class="inline-flex items-center gap-1.5 text-[11.5px] font-medium text-amber-600"><Glyph name="pause" size={12} />Paused</span>
|
||||
) : (
|
||||
<span class="inline-flex items-center gap-1.5 text-[11.5px] font-medium text-sky-600"><Glyph name="activity" size={12} />Health-check</span>
|
||||
)}
|
||||
</td>
|
||||
<td class="px-3">
|
||||
{r.inbox ? (
|
||||
<span class={`inline-flex items-center gap-1.5 font-mono text-[11.5px] font-medium tabular-nums ${r.inbox >= 90 ? 'text-emerald-600' : 'text-amber-600'}`}>
|
||||
<span class={`size-1.5 rounded-full ${r.inbox >= 90 ? 'bg-emerald-500' : 'bg-amber-500'}`}></span>{r.inbox}%
|
||||
</span>
|
||||
) : (
|
||||
<span class="inline-flex items-center gap-1.5 font-mono text-[11px] text-slate-400"><span class="size-1.5 rounded-full bg-slate-300"></span>{r.collecting}</span>
|
||||
)}
|
||||
</td>
|
||||
<td class="px-3">
|
||||
<span class={`inline-flex items-center gap-1.5 text-[11px] font-medium ${h.text}`}>
|
||||
<span class={`size-1.5 rounded-full ${h.dot}`}></span>
|
||||
<span class="uppercase tracking-[0.08em]">{h.label} {r.health[1]}</span>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- The drawer, open on the Warmup tab of the selected mailbox. -->
|
||||
<div class="absolute left-[704px] top-[24px] w-[516px] h-[720px] overflow-hidden rounded-t-[12px] bg-white ring-1 ring-slate-900/[0.08] shadow-[-30px_40px_90px_-40px_rgba(15,23,42,0.6)]">
|
||||
<DrawerHead addr="alex@northwind.dev" provider="Google Workspace" mark="google" active="Warmup" />
|
||||
<div class="divide-y divide-slate-200/60">
|
||||
<div class="px-5 py-4 flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2.5 min-w-0">
|
||||
<span class="inline-flex size-8 shrink-0 items-center justify-center rounded-lg bg-orange-50 text-orange-600"><Glyph name="flame" size={16} class="flame-flicker" /></span>
|
||||
<div class="min-w-0">
|
||||
<div class="text-[12.5px] font-medium text-slate-900">Warming up</div>
|
||||
<div class="truncate text-[11px] text-slate-400">Day 18 of the ramp · plain-text exchanges with the pool</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<span class="h-8 px-3 rounded-md border border-slate-200 inline-flex items-center gap-1.5 text-[12px] font-medium text-slate-700"><Glyph name="pause" size={13} />Pause</span>
|
||||
<span class="h-8 px-3 rounded-md border border-slate-200 inline-flex items-center text-[12px] font-medium text-slate-600">Stop</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 py-4">
|
||||
<div class={EYEBROW}>Today</div>
|
||||
<div class="mt-2 text-[12.5px] text-slate-700">Sending <b class="font-semibold text-slate-900 tabular-nums">19</b> of <b class="font-semibold text-slate-900 tabular-nums">27</b> · day 18</div>
|
||||
<div class="mt-2.5 h-1.5 rounded-full bg-slate-100 overflow-hidden"><div class="enter-grow-x h-full w-[70%] rounded-full bg-orange-400" style="--d:600ms"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class={EYEBROW}>Warmup reputation</span>
|
||||
<span class="text-[10.5px] text-slate-400">Premium pool</span>
|
||||
</div>
|
||||
<span class="inline-flex items-center gap-1 text-[11px] font-medium text-emerald-600"><Glyph name="shieldCheck" size={14} />Healthy</span>
|
||||
</div>
|
||||
<div class="mt-2.5 flex flex-wrap items-center gap-x-4 gap-y-1 text-[11.5px] text-slate-500">
|
||||
<span>Sent to <b class="text-slate-900 tabular-nums">41</b> partners</span>
|
||||
<span><b class="text-slate-900 tabular-nums">33</b> domains</span>
|
||||
<span><b class="text-slate-900 tabular-nums">26</b> workspaces</span>
|
||||
<span>Received <b class="text-slate-900 tabular-nums">38</b> from <b class="text-slate-900 tabular-nums">35</b> partners</span>
|
||||
<span class="text-slate-400">last 7 days</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 py-4">
|
||||
<div class={EYEBROW}>Ramp configuration</div>
|
||||
<div class="mt-3 grid grid-cols-2 gap-x-4 gap-y-3.5">
|
||||
{ramp.map((f) => (
|
||||
<div>
|
||||
<div class="mb-1 text-[12px] font-medium text-slate-700">{f.label}</div>
|
||||
<div class="flex h-9 items-center rounded-md border border-slate-200 bg-white">
|
||||
<span class="flex-1 px-2.5 text-right text-[12.5px] tabular-nums text-slate-900">{f.value}</span>
|
||||
<span class="pr-2 text-[11px] text-slate-400 whitespace-nowrap">{f.suffix}</span>
|
||||
<span class="flex self-stretch flex-col border-l border-slate-200 text-slate-400">
|
||||
<span class="flex flex-1 items-center px-1"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="m18 15-6-6-6 6" /></svg></span>
|
||||
<span class="flex flex-1 items-center border-t border-slate-200 px-1"><Glyph name="chevronDown" size={12} /></span>
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-[10.5px] leading-relaxed text-slate-400">{f.hint}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 py-4">
|
||||
<div class={EYEBROW}>Sending window</div>
|
||||
<div class="mt-3 flex items-center gap-2 text-[12.5px] text-slate-700">
|
||||
<span class="h-8 px-2.5 rounded-md border border-slate-200 inline-flex items-center gap-1.5 tabular-nums"><Glyph name="clock" size={13} class="text-slate-400" />08:00</span>
|
||||
<span class="text-[11.5px] text-slate-400">to</span>
|
||||
<span class="h-8 px-2.5 rounded-md border border-slate-200 inline-flex items-center gap-1.5 tabular-nums"><Glyph name="clock" size={13} class="text-slate-400" />20:00</span>
|
||||
<span class="ml-auto inline-flex gap-1">
|
||||
{days.map((d, i) => (
|
||||
<span class={`h-7 w-9 rounded-md inline-flex items-center justify-center text-[11px] font-medium ${i < 5 ? 'bg-sky-50 text-sky-700 ring-1 ring-sky-200' : 'text-slate-400 ring-1 ring-slate-200'}`}>{d}</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Warmup traffic as it happens, newest on top. -->
|
||||
<div class={`${FLOAT} left-[76px] top-[452px] w-[300px] overflow-hidden`}>
|
||||
<div class="flex items-center gap-2 border-b border-slate-200 px-4 py-3">
|
||||
<span class={EYEBROW}>Warmup activity</span>
|
||||
<span class="ml-auto inline-flex items-center gap-1.5 text-[10.5px] text-emerald-600"><span class="size-1.5 rounded-full bg-emerald-500 animate-pulse"></span>Live</span>
|
||||
</div>
|
||||
<div class="h-[208px] overflow-hidden">
|
||||
<div class="wh-feed">
|
||||
{[...feed, ...feed].map((f) => (
|
||||
<div class="flex h-[52px] items-center gap-2.5 border-b border-slate-100 px-4">
|
||||
<span class={`inline-flex size-7 shrink-0 items-center justify-center rounded-md ${f.tone}`}><Glyph name={f.icon} size={13} /></span>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-[12px] text-slate-800">{f.text}</div>
|
||||
<div class="truncate text-[10.5px] text-slate-400">{f.sub}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 border-t border-slate-100 px-4 py-2.5 text-[10.5px] text-slate-400">
|
||||
<Ico name="eyeOff" size={12} />Never shown in your unibox
|
||||
</div>
|
||||
</div>
|
||||
</HeroStage>
|
||||
|
||||
<style>
|
||||
/* Rows are 52px; the feed steps down one at a time so a new event lands on top. */
|
||||
.wh-feed { transform: translateY(-260px); animation: wh-feed 16s cubic-bezier(0.65, 0, 0.35, 1) infinite; }
|
||||
@keyframes wh-feed {
|
||||
0%, 17% { transform: translateY(-260px); }
|
||||
20%, 37% { transform: translateY(-208px); }
|
||||
40%, 57% { transform: translateY(-156px); }
|
||||
60%, 77% { transform: translateY(-104px); }
|
||||
80%, 97% { transform: translateY(-52px); }
|
||||
100% { transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
+150
-699
@@ -1,733 +1,184 @@
|
||||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import HeroAtmosphere from '../components/HeroAtmosphere.astro';
|
||||
import Icon from '../components/Icon.astro';
|
||||
import CTA from '../components/CTA.astro';
|
||||
import PillarPosts from '../components/PillarPosts.astro';
|
||||
import SectionIntro from '../components/page/SectionIntro.astro';
|
||||
import Rule from '../components/page/Rule.astro';
|
||||
import Faq from '../components/page/Faq.astro';
|
||||
import FlowHeroScene from '../components/scenes/campaigns/FlowHeroScene.astro';
|
||||
import StepEditorScene from '../components/scenes/campaigns/StepEditorScene.astro';
|
||||
import ThreadScene from '../components/scenes/campaigns/ThreadScene.astro';
|
||||
import LeadsScene from '../components/scenes/campaigns/LeadsScene.astro';
|
||||
import ScheduleScene from '../components/scenes/campaigns/ScheduleScene.astro';
|
||||
import GuardrailScene from '../components/scenes/campaigns/GuardrailScene.astro';
|
||||
import LimitsScene from '../components/scenes/campaigns/LimitsScene.astro';
|
||||
import RotationScene from '../components/scenes/campaigns/RotationScene.astro';
|
||||
import EspScene from '../components/scenes/campaigns/EspScene.astro';
|
||||
|
||||
// ===========================================================================
|
||||
// Campaigns page. The centrepiece is a faithful, static recreation of the
|
||||
// in-product sequence builder (web/src/components/app/campaigns/sequences):
|
||||
// cards with an icon tile, a kicker, a title and a one-line summary; branch
|
||||
// pills heading one column per path; and wait chips on the lines into each
|
||||
// step. 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}}`}.
|
||||
// ===========================================================================
|
||||
// Behaviour from docs/content/docs/guides/campaigns.mdx and sequences.mdx.
|
||||
|
||||
// --- 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 it goes out.',
|
||||
},
|
||||
{
|
||||
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. Each signal gets its own column, and Otherwise takes everyone else.',
|
||||
},
|
||||
{
|
||||
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: 'End', tag: 'ends a path',
|
||||
badge: 'bg-rose-100 text-rose-600 ring-rose-200/70',
|
||||
desc: 'Closes the sequence for that contact. A path with nothing under it ends there, and a condition can end it outright.',
|
||||
},
|
||||
const writing = [
|
||||
['Variables', 'First name, company, any custom field, and the sending mailbox’s own name'],
|
||||
['Plain text', 'Switch a campaign to plain text, with no HTML part at all'],
|
||||
['Attachments', 'Upload files to one step and they go out with that step only'],
|
||||
];
|
||||
const threading = [
|
||||
['One mailbox', 'Rotation picks the mailbox for the first email, and it sends the rest'],
|
||||
['Out of office', 'An auto-reply is not a reply. It holds the next step until they are back'],
|
||||
['New thread', 'Turn off Reply in thread on a step to open a fresh conversation'],
|
||||
];
|
||||
|
||||
// --- 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 card = 'mt-6 text-[21px] md:text-[23px] tracking-[-0.02em] text-slate-900';
|
||||
const cardBody = 'mt-2 max-w-xl text-[16px] md:text-[17px] leading-[1.55] text-slate-500';
|
||||
|
||||
const faq: [string, string][] = [
|
||||
['Do follow-ups come from the same mailbox?', 'Yes. Rotation chooses a mailbox for a contact’s first email, and that mailbox sends the rest of their sequence, as replies in the same thread. Three emails from three addresses is not a conversation.'],
|
||||
['What happens when someone replies?', 'With stop on reply on, the rest of the cold sequence stops for that contact, while any reply branch on the email they answered still runs. Out-of-office replies don’t count as replies; they hold the next step until the person is back.'],
|
||||
['What can a branch check?', 'Whether the contact opened or clicked within a window of 1 to 60 days, or didn’t; whether they replied, and whether the reply was positive, negative or neutral; the intent read from their reply, such as wants pricing, not now or wrong person; whether the reply was an out-of-office message; or a random share of contacts. Conditions are checked left to right and the first match wins.'],
|
||||
['How many A/B variants can a step have?', 'Up to five on top of the original, with any traffic split you like. Each variant reports sends, opens, replies and bounces, and Warmbly names a winner once the numbers support one.'],
|
||||
['How many emails does a campaign send a day?', 'The sum of its mailboxes’ budgets. Each mailbox sends at most 50 cold emails a day by default (a newly connected one plans about 30), and a campaign’s own daily limit can only lower that. A mailbox still warming up sends at most 5 cold emails a day. Today’s sending plan on the campaign shows the number and every limit that set it.'],
|
||||
['Can a campaign pause itself?', 'Yes. Turn on auto-pause and a campaign stops once its bounce rate reaches 5% or its complaint rate 0.10% (the defaults, and both can be changed), or its reply rate drops below a floor you set, after a minimum number of sends.'],
|
||||
['Does it send in the recipient’s timezone?', 'Campaigns send inside the weekly windows you set, in the campaign’s timezone. Turn on send-time optimization and each email also waits for the recipient’s own local hours.'],
|
||||
['Can I send plain text only?', 'Yes. You can write in the rich editor or switch a campaign to plain text, which many cold senders prefer.'],
|
||||
];
|
||||
|
||||
// --- 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: 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', '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', 'A reply-to-opt-out line or unsubscribe link in every email, plus RFC 8058 one-click. Suppression is workspace-wide.'],
|
||||
['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.'],
|
||||
];
|
||||
|
||||
// --- 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 leads across the pool under each mailbox cap, the 600s spacing rule, and its current health band, so no single mailbox carries the campaign. A lead keeps the mailbox that sent its first email, so every follow-up comes from the same address.'],
|
||||
['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);
|
||||
---
|
||||
<Layout
|
||||
title="Cold Email Sequences & Campaign Builder | Warmbly"
|
||||
description="A visual builder for multi-step, branching sequences. Email steps, waits, branches that route on opens, clicks and replies, A/B variants, sent through a pool of mailboxes in the recipient time zone."
|
||||
title="Cold Email Campaigns and Sequence Builder | Warmbly"
|
||||
description="Build multi-step cold email sequences on a visual canvas: threaded follow-ups, branches on opens, clicks and replies, A/B variants, rotation across mailboxes and auto-pause."
|
||||
>
|
||||
<!-- ============================================================
|
||||
HERO · clean, with floating builder fragments (xl+)
|
||||
============================================================ -->
|
||||
<section class="relative isolate overflow-hidden">
|
||||
<HeroAtmosphere />
|
||||
|
||||
<!-- Floating fragments that tease the builder. xl and up only. -->
|
||||
<div class="hidden xl:block absolute inset-0 z-0 pointer-events-none select-none" aria-hidden="true">
|
||||
|
||||
<!-- TOP-LEFT · a mini first-email card -->
|
||||
<div class="hero-float absolute top-[12%] left-[calc(50%_-_484px)] z-30" style="--drift:-10px;animation-duration:7.6s;animation-delay:-1.4s">
|
||||
<div class="rotate-[-2deg] w-[230px] flex items-center gap-2 rounded-[12px] bg-white ring-1 ring-slate-900/5 shadow-[0_2px_4px_-2px_rgba(2,32,71,0.25),0_18px_36px_-12px_rgba(2,32,71,0.5)] px-2.5 py-2">
|
||||
<span class="inline-flex size-7 shrink-0 items-center justify-center rounded-md bg-sky-50 text-sky-600 ring-1 ring-inset ring-sky-100"><Icon name="mail" size={13} /></span>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-[12px] font-medium text-slate-900">First touch</div>
|
||||
<div class="truncate text-[11px] text-slate-400">First email<span class="text-slate-500"> · Quick question, Maya</span></div>
|
||||
</div>
|
||||
<!-- HERO: copy on top, the running campaign's flow canvas underneath. -->
|
||||
<section class="container-page pt-14 md:pt-24">
|
||||
<div class="grid items-end gap-8 lg:grid-cols-[1.3fr_1fr]">
|
||||
<div>
|
||||
<div class="text-[14px] text-slate-500">Campaigns</div>
|
||||
<h1 class="mt-4 max-w-3xl text-[40px] sm:text-[52px] md:text-[64px] font-medium tracking-[-0.05em] leading-[1.02] text-slate-900">
|
||||
Sequences that read like one person writing back.
|
||||
</h1>
|
||||
</div>
|
||||
<div class="lg:pb-2">
|
||||
<p class="text-[17px] md:text-[18px] leading-[1.55] text-slate-500">
|
||||
Draw the sequence on a canvas, branch it on what each contact does, and send it across your mailboxes. Follow-ups land in the same thread, from the same address, and stop the moment someone replies.
|
||||
</p>
|
||||
<div class="mt-7 flex flex-wrap items-center gap-3">
|
||||
<a href="https://app.warmbly.com/auth/register" class="inline-flex h-11 items-center gap-2 rounded-full bg-slate-900 px-5 text-[15px] font-medium text-white transition-colors hover:bg-slate-800">
|
||||
Build a sequence <Icon name="arrowRight" size={14} />
|
||||
</a>
|
||||
<a href="https://docs.warmbly.com/guides/sequences/" class="inline-flex h-11 items-center rounded-full border border-slate-200 px-5 text-[15px] text-slate-800 transition-colors hover:border-slate-300 hover:bg-slate-50">
|
||||
Read the guide
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TOP-RIGHT · a branch pill over its column (focal) -->
|
||||
<div class="hero-float absolute top-[14%] right-[calc(50%_-_470px)] z-30" style="--drift:-12px;animation-duration:7.2s;animation-delay:-2.1s">
|
||||
<div class="rotate-[2deg] inline-flex items-center rounded-full border border-emerald-200 bg-emerald-50 px-2.5 py-1 text-[12px] font-medium text-emerald-700 shadow-[0_2px_4px_-2px_rgba(2,32,71,0.25),0_16px_32px_-12px_rgba(2,32,71,0.5)]">
|
||||
Opened the email within 2d · instant
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MID-RIGHT · a wait chip on a line -->
|
||||
<div class="hero-float absolute top-[30%] right-[calc(50%_-_404px)] z-20" style="--drift:-7px;animation-duration:8.2s;animation-delay:-0.7s">
|
||||
<div class="rotate-[-2deg] inline-flex items-center gap-1.5 h-7 px-2.5 rounded-full bg-white/95 ring-1 ring-slate-900/5 shadow-[0_8px_20px_-10px_rgba(2,32,71,0.4)]">
|
||||
<Icon name="clock" size={12} class="text-slate-400" />
|
||||
<span class="text-[11.5px] font-medium text-slate-600">2 days</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BOTTOM-LEFT · an action card fragment -->
|
||||
<div class="hero-float absolute top-[82%] left-[calc(50%_-_466px)] z-20" style="--drift:-9px;animation-duration:7.9s;animation-delay:-2.6s">
|
||||
<div class="rotate-[2deg] inline-flex items-center gap-2 rounded-[12px] bg-white ring-1 ring-slate-900/5 shadow-[0_2px_3px_-2px_rgba(2,32,71,0.2),0_14px_30px_-14px_rgba(2,32,71,0.45)] px-2.5 py-2">
|
||||
<span class="inline-flex size-6 items-center justify-center rounded-md bg-sky-50 text-sky-600 ring-1 ring-inset ring-sky-100"><Icon name="send" size={11} /></span>
|
||||
<span class="text-[12px] font-semibold text-slate-900">Notify #ae-west</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BOTTOM-RIGHT · an End terminal -->
|
||||
<div class="hero-float absolute top-[85%] right-[calc(50%_-_438px)] z-10" style="--drift:-8px;animation-duration:7.0s;animation-delay:-3.2s">
|
||||
<div class="rotate-[-2deg] inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-3.5 py-1.5 shadow-[0_10px_24px_-12px_rgba(2,32,71,0.4)]">
|
||||
<Icon name="flag" size={11} class="text-rose-500" />
|
||||
<span class="text-[11.5px] font-medium text-slate-600">End</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="container-page relative z-10 pt-16 md:pt-24 pb-24 md:pb-32 text-center">
|
||||
<div class="inline-flex items-center gap-2 h-7 pl-1 pr-3 rounded-full bg-white/15 backdrop-blur ring-1 ring-white/25 text-[12px] text-white/95 shadow-[0_4px_14px_-4px_rgba(0,0,0,0.25)]">
|
||||
<span class="inline-flex items-center h-5 px-1.5 rounded-full text-[10.5px] font-semibold uppercase tracking-[0.06em] bg-white" style="color:#0369a1;">
|
||||
Campaigns
|
||||
</span>
|
||||
A visual builder for branching sequences
|
||||
</div>
|
||||
|
||||
<h1 class="mt-8 md:mt-10 text-[44px] sm:text-6xl md:text-[72px] lg:text-[80px] font-semibold tracking-[-0.04em] leading-[0.98] text-white max-w-4xl mx-auto">
|
||||
Draw the whole sequence,<br/>branch by branch.
|
||||
</h1>
|
||||
<p class="mt-6 text-[17px] md:text-[19px] text-white/80 max-w-2xl mx-auto leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||
<a href="https://app.warmbly.com/auth/register" class="inline-flex h-11 px-5 items-center gap-2 rounded-[10px] text-[14.5px] font-semibold bg-white hover:bg-white hover:-translate-y-0.5 hover:shadow-[0_12px_28px_-6px_rgba(0,0,0,0.3)] transition-all duration-200 ease-out shadow-[0_4px_14px_-2px_rgba(0,0,0,0.18)]">
|
||||
<span style="color:#075985;">Build your first sequence</span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#075985" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="#builder" class="inline-flex h-11 px-5 items-center rounded-[10px] text-[14.5px] font-medium bg-white/10 backdrop-blur ring-1 ring-white/40 text-white hover:bg-white/20 hover:-translate-y-0.5 transition-all duration-200 ease-out">
|
||||
See the builder
|
||||
</a>
|
||||
</div>
|
||||
<div class="mt-14 md:mt-16">
|
||||
<FlowHeroScene />
|
||||
</div>
|
||||
<p class="mt-5 text-[13.5px] text-slate-400">Up to 50 steps per sequence · Branches on opens, clicks and reply intent · Edited together, live</p>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.hero-float { animation-name: heroFloat; animation-timing-function: ease-in-out; animation-iteration-count: infinite; animation-direction: alternate; will-change: transform; }
|
||||
@keyframes heroFloat { from { transform: translateY(0); } to { transform: translateY(var(--drift, -10px)); } }
|
||||
@media (prefers-reduced-motion: reduce) { .hero-float { animation: none; } }
|
||||
</style>
|
||||
|
||||
<!-- ============================================================
|
||||
THE BUILDER · faithful recreation of the sequence canvas
|
||||
============================================================ -->
|
||||
<section id="builder" class="border-y border-[color:var(--border)] bg-[color:var(--surface-1)]/40 py-20 md:py-28 scroll-mt-4">
|
||||
<div class="container-page">
|
||||
<div class="max-w-2xl mb-10 md:mb-12">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] font-mono text-[#0284c7] mb-3">The builder</div>
|
||||
<h2 class="text-[32px] md:text-[44px] font-semibold tracking-[-0.03em] leading-[1.05] text-heading">
|
||||
The sequence is a map, not a list.
|
||||
</h2>
|
||||
<p class="mt-4 text-[15.5px] text-foreground/70 leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Builder frame -->
|
||||
<div class="rounded-[16px] bg-white ring-1 ring-[color:var(--border)] overflow-hidden shadow-[0_40px_90px_-30px_rgba(2,32,71,0.32),0_12px_30px_-12px_rgba(15,23,42,0.1)]">
|
||||
|
||||
<!-- Toolbar, mirrors the in-product canvas panels -->
|
||||
<div class="flex items-center justify-between gap-3 px-3 sm:px-4 py-2.5 border-b border-[color:var(--border)] bg-[color:var(--surface-1)]/70">
|
||||
<span class="inline-flex items-center gap-2 h-8 rounded-md border border-slate-200 bg-white px-2.5">
|
||||
<span class="text-[11.5px] text-slate-600">Stop on reply</span>
|
||||
<span class="relative inline-flex h-5 w-9 items-center rounded-full bg-sky-600">
|
||||
<span class="inline-block size-4 translate-x-4 rounded-full bg-white shadow-sm"></span>
|
||||
</span>
|
||||
</span>
|
||||
<div class="hidden sm:flex items-center gap-0.5 rounded-lg border border-slate-200 bg-white p-0.5">
|
||||
<span class="inline-flex size-7 items-center justify-center rounded-md text-slate-500">−</span>
|
||||
<span class="min-w-11 text-center text-[11.5px] tabular-nums text-slate-600">100%</span>
|
||||
<span class="inline-flex size-7 items-center justify-center rounded-md text-slate-500">+</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Canvas -->
|
||||
<div class="builder-canvas relative px-4 sm:px-8 py-10 md:py-14">
|
||||
<div class="relative mx-auto max-w-[640px]">
|
||||
|
||||
<!-- Start -->
|
||||
<div class="flow-node mx-auto w-full sm:w-[280px]" style="--i:0">
|
||||
<div class="flow-card">
|
||||
<span class="flow-tile bg-violet-50 text-violet-600 ring-violet-100"><Icon name="arrowRight" size={15} /></span>
|
||||
<div class="min-w-0"><div class="flow-title">Contact enters the campaign</div><div class="flow-sub"><span class="flow-kicker">Start</span> · First email right away</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flow-conn flow-node" style="--i:1">
|
||||
<span class="flow-line h-8"></span>
|
||||
</div>
|
||||
|
||||
<!-- First email -->
|
||||
<div class="flow-node mx-auto w-full sm:w-[280px]" style="--i:2">
|
||||
<div class="flow-card">
|
||||
<span class="flow-tile bg-sky-50 text-sky-600 ring-sky-100"><Icon name="mail" size={15} /></span>
|
||||
<div class="min-w-0"><div class="flow-title">First touch</div><div class="flow-sub"><span class="flow-kicker">First email</span> · {`Quick question, {{.FirstName}}`}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flow-node hidden sm:flex justify-center" style="--i:3">
|
||||
<span class="flow-line h-5"></span>
|
||||
</div>
|
||||
|
||||
<!-- one column per path, each headed by its pill -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-8">
|
||||
|
||||
<div class="flow-node" style="--i:4">
|
||||
<div class="flex flex-col items-center">
|
||||
<span class="flow-branch flow-branch-then">Opened the email within 2d · instant</span>
|
||||
<span class="flow-line h-4"></span>
|
||||
</div>
|
||||
<div class="flow-card">
|
||||
<span class="flow-tile bg-sky-50 text-sky-600 ring-sky-100"><Icon name="mail" size={15} /></span>
|
||||
<div class="min-w-0"><div class="flow-title">Case study nudge</div><div class="flow-sub"><span class="flow-kicker">Reply in thread</span> · {`Quick question, {{.FirstName}}`}</div></div>
|
||||
</div>
|
||||
<div class="flow-conn"><span class="flow-line"></span><span class="flow-wait"><Icon name="clock" size={12} class="text-slate-400" /> 3 days</span><span class="flow-line"></span></div>
|
||||
<div class="flow-card">
|
||||
<span class="flow-tile bg-sky-50 text-sky-600 ring-sky-100"><Icon name="mail" size={15} /></span>
|
||||
<div class="min-w-0"><div class="flow-title">Last call</div><div class="flow-sub"><span class="flow-kicker">Email</span> · Should I close this out?</div></div>
|
||||
</div>
|
||||
<div class="flow-conn"><span class="flow-line"></span><span class="flow-add">+</span></div>
|
||||
</div>
|
||||
|
||||
<div class="flow-node" style="--i:5">
|
||||
<div class="flex flex-col items-center">
|
||||
<span class="flow-branch flow-branch-else">Otherwise</span>
|
||||
<span class="flow-line h-4"></span>
|
||||
</div>
|
||||
<div class="flow-conn"><span class="flow-wait"><Icon name="clock" size={12} class="text-slate-400" /> 4 days</span><span class="flow-line"></span></div>
|
||||
<div class="flow-card">
|
||||
<span class="flow-tile bg-sky-50 text-sky-600 ring-sky-100"><Icon name="shuffle" size={14} /></span>
|
||||
<div class="min-w-0"><div class="flow-title">Move deal stage</div><div class="flow-sub"><span class="flow-kicker">Action</span> · Move the deal forward</div></div>
|
||||
</div>
|
||||
<div class="flow-conn"><span class="flow-line"></span><span class="flow-add">+</span></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- footer strip -->
|
||||
<div class="px-4 sm:px-7 py-3 border-t border-[color:var(--border)] bg-[color:var(--surface-1)]/60 flex flex-wrap items-center justify-between gap-2 text-[11.5px] font-mono text-muted-foreground">
|
||||
<span>3 emails · 1 condition · 1 action</span>
|
||||
<span class="hidden sm:inline">Saved as you edit · a + on any line adds a step there</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================
|
||||
STEP TYPES · the node kinds, with real styling
|
||||
============================================================ -->
|
||||
<section class="border-b border-[color:var(--border)] py-20 md:py-28">
|
||||
<div class="container-page">
|
||||
<div class="max-w-2xl mb-12">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Step types</div>
|
||||
<h2 class="text-[28px] md:text-[40px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading">
|
||||
Four kinds of node.
|
||||
</h2>
|
||||
<p class="mt-4 text-[15px] text-foreground/70 leading-relaxed">
|
||||
Everything on the canvas is one of four kinds of step. Add each from the + on any line, and the sequence runs exactly as it reads.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 lg:grid-cols-4 gap-px bg-[color:var(--border)] rounded-[14px] overflow-hidden ring-1 ring-[color:var(--border)]">
|
||||
{stepTypes.map((s) => (
|
||||
<div class="bg-white p-6">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<span class={`inline-flex size-8 items-center justify-center rounded-md ring-1 ${s.badge}`}>
|
||||
<Icon name={s.icon} size={15} />
|
||||
</span>
|
||||
<div>
|
||||
<div class="text-[15px] font-semibold text-heading leading-none">{s.name}</div>
|
||||
<div class="mt-1 text-[10px] font-mono uppercase tracking-[0.14em] text-muted-foreground">{s.tag}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-4 text-[13px] text-foreground/70 leading-relaxed">{s.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================
|
||||
BRANCHING & TIMING
|
||||
============================================================ -->
|
||||
<section class="border-b border-[color:var(--border)] bg-[color:var(--surface-1)]/40 py-20 md:py-28">
|
||||
<div class="container-page grid lg:grid-cols-[1fr_1.4fr] gap-12 lg:gap-20 items-start">
|
||||
<div class="lg:sticky lg:top-24" style="align-self: start;">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] font-mono text-[#0284c7] mb-3">Branching & timing</div>
|
||||
<h2 class="text-[28px] md:text-[40px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading">
|
||||
Route on what they do.
|
||||
</h2>
|
||||
<p class="mt-4 text-[15px] text-foreground/70 leading-relaxed max-w-md">
|
||||
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.
|
||||
</p>
|
||||
<ul class="mt-6 space-y-2.5 text-[14px] text-foreground/85">
|
||||
<li class="flex items-start gap-2"><Icon name="check" size={13} class="text-[#0284c7] mt-1 shrink-0" /><span>Then and else can each point at any step, an action, or a stop.</span></li>
|
||||
<li class="flex items-start gap-2"><Icon name="check" size={13} class="text-[#0284c7] mt-1 shrink-0" /><span>Waits are set per step, from 0 to 60 days, counted in business days.</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="rounded-[14px] bg-white ring-1 ring-[color:var(--border)] overflow-hidden">
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_92px_minmax(0,2.4fr)] px-5 py-3 bg-[color:var(--surface-1)] border-b border-[color:var(--border)] text-[10px] uppercase tracking-[0.18em] font-mono text-muted-foreground">
|
||||
<div>Signal</div>
|
||||
<div class="text-center">Timing</div>
|
||||
<div>What it catches</div>
|
||||
</div>
|
||||
{routing.map((r) => (
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_92px_minmax(0,2.4fr)] px-5 py-3.5 border-b border-[color:var(--border)] last:border-b-0 items-center gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class={`w-1.5 h-1.5 rounded-full shrink-0 ${r.instant ? 'bg-violet-500' : 'bg-slate-300'}`}></span>
|
||||
<span class="font-mono text-[12.5px] text-heading">{r.sig}</span>
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
{r.instant
|
||||
? <span class="inline-flex items-center gap-0.5 h-5 px-1.5 rounded text-[9.5px] font-mono uppercase tracking-[0.08em] bg-violet-50 text-violet-600 ring-1 ring-violet-200/70"><Icon name="zap" size={9} />instant</span>
|
||||
: <span class="inline-flex items-center h-5 px-1.5 rounded text-[9.5px] font-mono uppercase tracking-[0.08em] bg-slate-100 text-slate-500">window</span>
|
||||
}
|
||||
</div>
|
||||
<div class="text-[12.5px] text-foreground/70 leading-snug">{r.desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================
|
||||
TEMPLATING · Go templates, dark window + rendered preview
|
||||
============================================================ -->
|
||||
<section class="border-b border-[color:var(--border)] py-20 md:py-28">
|
||||
<div class="container-page grid lg:grid-cols-[1fr_1.3fr] gap-12 lg:gap-16 items-start">
|
||||
<div class="lg:sticky lg:top-24" style="align-self: start;">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Templating</div>
|
||||
<h2 class="text-[28px] md:text-[40px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading">
|
||||
Real personalisation, with safe defaults.
|
||||
</h2>
|
||||
<p class="mt-4 text-[15px] text-foreground/70 leading-relaxed max-w-md">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<div class="mt-6">
|
||||
<div class="text-[10px] font-mono uppercase tracking-[0.18em] text-muted-foreground mb-2">Available fields</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{mergeFields.map((f) => (
|
||||
<code class="inline-flex items-center h-6 px-2 rounded-md bg-[#0c1224] text-[11.5px] font-mono text-sky-300 ring-1 ring-white/10">{f}</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-[14px] bg-white ring-1 ring-[color:var(--border)] overflow-hidden">
|
||||
<!-- source -->
|
||||
<div class="bg-[#0c1224]">
|
||||
<div class="px-4 py-2.5 border-b border-white/10 flex items-center gap-2">
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-[#ff5f56]/80"></span>
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-[#ffbd2e]/80"></span>
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-[#27c93f]/80"></span>
|
||||
<span class="ml-2 text-[11px] font-mono text-white/55">first touch · body</span>
|
||||
</div>
|
||||
<div class="p-5 font-mono text-[12.5px] leading-[1.85] text-white/85">
|
||||
<div>Hi <span class="text-sky-300">{`{{.FirstName}}`}</span>,</div>
|
||||
<div class="h-3.5"></div>
|
||||
<div class="whitespace-pre-wrap">Saw <span class="text-violet-300">{`{{if .Trigger}}`}</span><span class="text-sky-300">{`{{.Trigger}}`}</span> at <span class="text-violet-300">{`{{end}}`}</span><span class="text-sky-300">{`{{ .Company | default "your team" }}`}</span> and the <span class="text-sky-300">{`{{.Role}}`}</span> role lined up with what we tend to help land.</div>
|
||||
<div class="h-3.5"></div>
|
||||
<div>Worth a 20-minute look next week?</div>
|
||||
<div class="h-3.5"></div>
|
||||
<div>Dana</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- rendered -->
|
||||
<div class="p-5">
|
||||
<div class="flex items-center gap-2 mb-3 text-[10px] font-mono uppercase tracking-[0.16em] text-muted-foreground">
|
||||
<Icon name="arrowRight" size={12} class="text-[#0284c7]" />
|
||||
Renders for Maya, with no Trigger on file
|
||||
</div>
|
||||
<div class="rounded-[10px] bg-[color:var(--surface-1)]/60 ring-1 ring-[color:var(--border)] p-4 text-[13px] leading-[1.85] text-foreground/80">
|
||||
<div>Hi <span class="text-[#0369a1] font-medium">Maya</span>,</div>
|
||||
<div class="h-3"></div>
|
||||
<div>Saw <span class="text-[#0369a1] font-medium">Northwind</span> and the <span class="text-[#0369a1] font-medium">VP RevOps</span> role lined up with what we tend to help land.</div>
|
||||
<div class="h-3"></div>
|
||||
<div>Worth a 20-minute look next week?</div>
|
||||
<div class="h-3"></div>
|
||||
<div>Dana</div>
|
||||
</div>
|
||||
<p class="mt-3 text-[12px] text-foreground/60 leading-relaxed">
|
||||
Her Trigger was blank, so the if skips the "at" clause and the sentence still reads clean.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================
|
||||
A/B VARIANTS · weight split on a step
|
||||
============================================================ -->
|
||||
<section class="border-b border-[color:var(--border)] bg-[color:var(--surface-1)]/40 py-20 md:py-28">
|
||||
<div class="container-page grid lg:grid-cols-[1fr_1.3fr] gap-12 lg:gap-16 items-start">
|
||||
<div class="lg:sticky lg:top-24" style="align-self: start;">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">A/B variants</div>
|
||||
<h2 class="text-[28px] md:text-[40px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading">
|
||||
Test the copy on any step.
|
||||
</h2>
|
||||
<p class="mt-4 text-[15px] text-foreground/70 leading-relaxed max-w-md">
|
||||
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.
|
||||
</p>
|
||||
<ul class="mt-6 space-y-2.5 text-[14px] text-foreground/85">
|
||||
<li class="flex items-start gap-2"><Icon name="check" size={13} class="text-[#0284c7] mt-1 shrink-0" /><span>Weights from 1 to 100, the original always carries the rest.</span></li>
|
||||
<li class="flex items-start gap-2"><Icon name="check" size={13} class="text-[#0284c7] mt-1 shrink-0" /><span>Pause a losing variant without touching the rest of the step.</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="rounded-[14px] bg-white ring-1 ring-[color:var(--border)] overflow-hidden">
|
||||
<div class="flex items-center gap-2 border-b border-[color:var(--border)] px-4 py-3">
|
||||
<Icon name="flask" size={14} class="text-slate-400" />
|
||||
<div>
|
||||
<div class="text-[10px] uppercase tracking-[0.14em] text-muted-foreground font-mono">A/B variants</div>
|
||||
<div class="text-[11.5px] text-foreground/55">3 variants, split by weight</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- weight bar -->
|
||||
<div class="px-4 pt-4">
|
||||
<div class="flex h-3 w-full overflow-hidden rounded-full ring-1 ring-[color:var(--border)]">
|
||||
<div class="bg-[#0284c7]" style={`width:${variants[0].weight}%`}></div>
|
||||
<div class="bg-sky-400" style={`width:${variants[1].weight}%`}></div>
|
||||
<div class="bg-sky-300" style={`width:${variants[2].weight}%`}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- variant rows -->
|
||||
<div class="p-4 space-y-2.5">
|
||||
{variants.map((v, i) => (
|
||||
<div class="flex items-center gap-3 rounded-[10px] ring-1 ring-[color:var(--border)] px-3 py-2.5">
|
||||
<span class={`w-2.5 h-2.5 rounded-full shrink-0 ${i === 0 ? 'bg-[#0284c7]' : i === 1 ? 'bg-sky-400' : 'bg-sky-300'}`}></span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[13px] font-medium text-heading">{v.name}</span>
|
||||
{v.original && <span class="inline-flex items-center h-4 px-1.5 rounded text-[9px] font-mono uppercase tracking-[0.1em] bg-slate-100 text-slate-500">original</span>}
|
||||
</div>
|
||||
<div class="mt-0.5 truncate font-mono text-[11.5px] text-foreground/55">{v.subject}</div>
|
||||
</div>
|
||||
<span class="font-mono tabular-nums text-[15px] font-semibold text-heading shrink-0">{v.weight}%</span>
|
||||
<!-- WRITING: one step's editor, with an A/B test running. -->
|
||||
<Rule class="mt-20" />
|
||||
<section class="container-page py-20 md:py-28">
|
||||
<div class="grid items-center gap-12 lg:grid-cols-[1fr_1.15fr] lg:gap-16">
|
||||
<div>
|
||||
<SectionIntro lead="Writing a step." title="Test up to six versions of any email." text="Add up to five variants to the original and drag the split however you like. Each version reports its sends, opens, replies and bounces, and Warmbly marks the winner once the numbers support one. A content check scores every draft and never blocks a send." />
|
||||
<dl class="mt-8 max-w-xl divide-y divide-slate-200 border-y border-slate-200">
|
||||
{writing.map(([k, v]) => (
|
||||
<div class="grid gap-1 py-3.5 sm:grid-cols-[130px_1fr] sm:gap-6">
|
||||
<dt class="text-[15px] font-medium text-slate-900">{k}</dt>
|
||||
<dd class="text-[15px] leading-[1.55] text-slate-500">{v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<StepEditorScene />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- THREADING: the follow-up in the contact's own inbox, and the reply that stops it. -->
|
||||
<Rule />
|
||||
<section class="container-page py-20 md:py-28">
|
||||
<div class="grid items-center gap-12 lg:grid-cols-[1.15fr_1fr] lg:gap-16">
|
||||
<div class="order-2 lg:order-1"><ThreadScene /></div>
|
||||
<div class="order-1 lg:order-2">
|
||||
<SectionIntro lead="Follow-ups." title="Every follow-up is a reply in the same conversation." text="Each follow-up carries the headers of the email before it, so it sits under your first message in the contact’s inbox and in your Sent folder. On Gmail it is posted into the same thread. When they answer, the rest of the sequence stops for them." />
|
||||
<dl class="mt-8 max-w-xl divide-y divide-slate-200 border-y border-slate-200">
|
||||
{threading.map(([k, v]) => (
|
||||
<div class="grid gap-1 py-3.5 sm:grid-cols-[130px_1fr] sm:gap-6">
|
||||
<dt class="text-[15px] font-medium text-slate-900">{k}</dt>
|
||||
<dd class="text-[15px] leading-[1.55] text-slate-500">{v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================
|
||||
SENDER POOL · one campaign, many mailboxes
|
||||
============================================================ -->
|
||||
<section class="border-b border-[color:var(--border)] py-20 md:py-28">
|
||||
<div class="container-page">
|
||||
<div class="max-w-2xl mb-12">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Sender pool</div>
|
||||
<h2 class="text-[28px] md:text-[40px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading">
|
||||
One sequence, many mailboxes.
|
||||
</h2>
|
||||
<p class="mt-4 text-[15px] text-foreground/70 leading-relaxed">
|
||||
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. Rotation happens per lead, not per email: whichever mailbox sends a contact their first email sends the rest of the sequence too.
|
||||
</p>
|
||||
<!-- LEADS: the whole list, wide, under a split heading. -->
|
||||
<Rule />
|
||||
<section class="container-page py-20 md:py-28">
|
||||
<div class="grid items-end gap-6 lg:grid-cols-[1.1fr_1fr] lg:gap-16">
|
||||
<SectionIntro lead="Leads." title="Every lead, where it is, and who is writing to it." />
|
||||
<div>
|
||||
<p class="text-[16px] md:text-[18px] leading-[1.6] text-slate-500">The Leads tab shows each lead’s progress, whether they opened, clicked or replied, the step they are on and the one mailbox their whole sequence comes from. Link a segment and anyone who joins it later is enrolled within minutes.</p>
|
||||
<a href="https://docs.warmbly.com/guides/campaigns/#lead-statuses" class="group mt-4 inline-flex items-center gap-2 text-[16px] text-[color:var(--brand)] hover:text-[color:var(--brand-strong)]">
|
||||
What each lead status means
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="transition-transform group-hover:translate-x-0.5"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-12 md:mt-14">
|
||||
<LeadsScene />
|
||||
</div>
|
||||
<p class="mt-5 max-w-3xl text-[14px] leading-[1.6] text-slate-400">Pause one lead until a date without unsubscribing them, or let their away message do it: the return date is read from the auto-reply where it can be, and the lead resumes a working day after it.</p>
|
||||
</section>
|
||||
|
||||
<div class="rounded-[14px] bg-white ring-1 ring-[color:var(--border)] overflow-hidden">
|
||||
<div class="grid grid-cols-[1.6fr_60px_1fr_70px_80px] px-5 sm:px-7 py-2.5 bg-[color:var(--surface-1)] border-b border-[color:var(--border)] text-[10px] uppercase tracking-[0.16em] font-mono text-muted-foreground">
|
||||
<div>Mailbox</div>
|
||||
<div>Role</div>
|
||||
<div>Today</div>
|
||||
<div class="text-right">Cap</div>
|
||||
<div class="text-right">Headroom</div>
|
||||
</div>
|
||||
{senderPool.map((m) => {
|
||||
const pct = Math.round((m.today / m.cap) * 100);
|
||||
return (
|
||||
<div class="grid grid-cols-[1.6fr_60px_1fr_70px_80px] px-5 sm:px-7 py-3 border-b border-[color:var(--border)] last:border-b-0 items-center text-[12.5px]">
|
||||
<div class="font-mono text-foreground/85 truncate">{m.addr}</div>
|
||||
<div class="text-foreground/55 text-[11px] font-mono">{m.role}</div>
|
||||
<div class="pr-4">
|
||||
<div class="h-1.5 rounded-full overflow-hidden bg-[color:var(--surface-1)] ring-1 ring-[color:var(--border)]">
|
||||
<div class="h-full bg-[#0284c7]" style={`width: ${pct}%`}></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right font-mono text-foreground/75">{m.cap}</div>
|
||||
<div class="text-right font-mono text-[#0284c7] font-semibold">{m.cap - m.today}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div class="px-5 sm:px-7 py-3 border-t border-[color:var(--border)] bg-[color:var(--surface-1)]/60 flex flex-wrap items-center justify-between gap-2 text-[11.5px] font-mono text-muted-foreground">
|
||||
<span>Round-robin per lead · health-aware · cap-aware</span>
|
||||
<span>Pool headroom: {totalCap - totalToday} sends today</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- SCHEDULE AND GUARDRAILS: a bento, two wide and three small. -->
|
||||
<Rule />
|
||||
<section class="container-page py-20 md:py-28">
|
||||
<SectionIntro lead="Sending." title="On your schedule, inside every limit." text="Each campaign keeps its own sending windows and timezone. Its volume is the sum of its mailboxes’ budgets, and the smallest limit always wins." />
|
||||
|
||||
<div class="mt-12 md:mt-16 grid gap-x-3 gap-y-12 md:grid-cols-2 lg:grid-cols-6">
|
||||
<article class="lg:col-span-3">
|
||||
<ScheduleScene />
|
||||
<h3 class={card}>Sending windows for each day</h3>
|
||||
<p class={cardBody}>Draw windows onto the week in the campaign’s timezone, and hold a new contact’s first email for up to 90 days after they join.</p>
|
||||
</article>
|
||||
<article class="lg:col-span-3">
|
||||
<GuardrailScene />
|
||||
<h3 class={card}>A campaign that stops itself</h3>
|
||||
<p class={cardBody}>Set a bounce, complaint or reply band once. Checked every 15 minutes, a crossed band pauses the campaign and logs the reason with its numbers.</p>
|
||||
</article>
|
||||
<article class="lg:col-span-2">
|
||||
<LimitsScene />
|
||||
<h3 class={card}>Today’s plan, worked out</h3>
|
||||
<p class={cardBody}>Every limit that lowered today’s number, and what is still to go.</p>
|
||||
</article>
|
||||
<article class="lg:col-span-2">
|
||||
<RotationScene />
|
||||
<h3 class={card}>Rotation across mailboxes</h3>
|
||||
<p class={cardBody}>The mailbox idle longest starts the next lead, inside its own cap and gap.</p>
|
||||
</article>
|
||||
<article class="md:col-span-2 lg:col-span-2">
|
||||
<EspScene />
|
||||
<h3 class={card}>Provider matching</h3>
|
||||
<p class={cardBody}>Google recipients from Google mailboxes and Microsoft from Microsoft, when you want it.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================
|
||||
AUTO-STOP RULES
|
||||
============================================================ -->
|
||||
<section class="border-b border-[color:var(--border)] bg-[color:var(--surface-1)]/40 py-20 md:py-24">
|
||||
<div class="container-page">
|
||||
<div class="max-w-2xl mb-12">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Auto-stop rules</div>
|
||||
<h2 class="text-[28px] md:text-[40px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading">
|
||||
Six reasons a contact stops getting mail.
|
||||
</h2>
|
||||
<p class="mt-4 text-[15px] text-foreground/70 leading-relaxed">
|
||||
Suppression carries across every sequence in the workspace. A bounced or opted-out contact never gets picked back up by another sequence by accident.
|
||||
</p>
|
||||
</div>
|
||||
<Rule />
|
||||
<Faq items={faq} title="Campaigns, answered." />
|
||||
|
||||
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-px bg-[color:var(--border)] rounded-[12px] overflow-hidden ring-1 ring-[color:var(--border)]">
|
||||
{autoStop.map(([k, v], i) => (
|
||||
<div class="bg-white p-6">
|
||||
<div class="flex items-baseline justify-between mb-2">
|
||||
<span class="text-[10.5px] font-mono uppercase tracking-[0.18em] text-muted-foreground">Rule {String(i + 1).padStart(2, '0')}</span>
|
||||
<span class="inline-flex items-center gap-1 text-[10.5px] font-mono text-rose-600">
|
||||
<span class="w-1 h-1 rounded-full bg-rose-500"></span>
|
||||
stop
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-[15px] font-semibold text-heading">{k}</div>
|
||||
<p class="mt-1.5 text-[13px] text-foreground/65 leading-relaxed">{v}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================
|
||||
LIMITS · clean spec sheet
|
||||
============================================================ -->
|
||||
<section class="py-20 md:py-28">
|
||||
<div class="container-page">
|
||||
<div class="max-w-2xl mb-12">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Limits</div>
|
||||
<h2 class="text-[32px] md:text-[44px] font-semibold tracking-[-0.03em] leading-[1.05] text-heading">
|
||||
The numbers a sequence respects.
|
||||
</h2>
|
||||
<p class="mt-4 text-[15.5px] text-foreground/70 leading-relaxed">
|
||||
Hard limits from the codebase. Override what you can per step, but the per-mailbox cap is enforced server-side regardless.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-x-12 gap-y-px">
|
||||
{limits.map((d, i) => (
|
||||
<div class="grid grid-cols-[1fr_auto] gap-6 py-6 border-b border-[color:var(--border)] items-baseline hover:bg-[color:var(--surface-1)]/40 -mx-3 px-3 rounded-[6px] transition-colors">
|
||||
<div>
|
||||
<div class="text-[10.5px] uppercase tracking-[0.22em] font-mono text-muted-foreground">
|
||||
{String(i + 1).padStart(2, '0')} · {d.k}
|
||||
</div>
|
||||
<p class="mt-2 text-[13.5px] text-foreground/65 leading-relaxed max-w-md">{d.why}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-[20px] md:text-[24px] font-semibold tracking-[-0.02em] text-heading font-mono whitespace-nowrap">{d.v}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="mt-8 text-[11.5px] font-mono text-muted-foreground">
|
||||
Source: <span class="text-foreground/75">internal/scheduler/campaign_scheduler.go</span> · <span class="text-foreground/75">internal/config/constants.go</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================
|
||||
FAQ
|
||||
============================================================ -->
|
||||
<section class="bg-[color:var(--surface-1)]/40 border-t border-[color:var(--border)] py-20 md:py-24">
|
||||
<div class="container-page grid lg:grid-cols-[1fr_1.8fr] gap-12 lg:gap-20 items-start">
|
||||
<div class="lg:sticky lg:top-24" style="align-self: start;">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Campaign FAQ</div>
|
||||
<h2 class="text-[28px] md:text-[36px] font-semibold tracking-[-0.025em] leading-[1.08] text-heading">
|
||||
Five questions about how sequences run.
|
||||
</h2>
|
||||
</div>
|
||||
<div class="divide-y divide-[color:var(--border)]" data-faq>
|
||||
{faq.map(([q, a]) => (
|
||||
<div class="faq-row py-2">
|
||||
<button type="button" class="faq-trigger group w-full flex items-start justify-between gap-4 py-3 text-left" aria-expanded="false">
|
||||
<span class="text-[15.5px] font-medium text-heading group-hover:text-[#0369a1] transition-colors">{q}</span>
|
||||
<span class="faq-icon shrink-0 inline-flex items-center justify-center w-7 h-7 rounded-full bg-white ring-1 ring-[color:var(--border)] text-foreground/60 transition-transform duration-300 ease-out">
|
||||
<Icon name="plus" size={12} />
|
||||
</span>
|
||||
</button>
|
||||
<div class="faq-panel grid grid-rows-[0fr] transition-[grid-template-rows] duration-300 ease-out">
|
||||
<div class="overflow-hidden">
|
||||
<p class="pb-4 pr-12 text-[14px] text-foreground/75 leading-relaxed max-w-2xl">{a}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<script is:inline>
|
||||
(function () {
|
||||
const faq = document.querySelector('[data-faq]');
|
||||
if (!faq) return;
|
||||
faq.querySelectorAll('.faq-trigger').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
const row = btn.closest('.faq-row');
|
||||
const panel = row.querySelector('.faq-panel');
|
||||
const icon = btn.querySelector('.faq-icon');
|
||||
const open = btn.getAttribute('aria-expanded') === 'true';
|
||||
btn.setAttribute('aria-expanded', String(!open));
|
||||
panel.style.gridTemplateRows = open ? '0fr' : '1fr';
|
||||
if (icon) icon.style.transform = open ? 'rotate(0deg)' : 'rotate(45deg)';
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<CTA
|
||||
title="Build your first sequence in five minutes."
|
||||
description="Connect a mailbox, pick a contact list, and draw the first step."
|
||||
primaryLabel="Start free"
|
||||
primaryHref="https://app.warmbly.com/auth/register"
|
||||
secondaryLabel="Read the cold-email rules"
|
||||
secondaryHref="https://docs.warmbly.com/learn/cold-email-rules/"
|
||||
<PillarPosts
|
||||
title="Read more about sending"
|
||||
items={[
|
||||
{ kicker: 'Volume', href: '/blog/how-many-cold-emails-per-day-per-mailbox/', title: 'How many cold emails per day per mailbox' },
|
||||
{ kicker: 'Setup', href: '/blog/how-many-mailboxes-and-domains-cold-email/', title: 'How many mailboxes and domains you need for cold email' },
|
||||
{ kicker: 'Limits', href: '/blog/mailbox-budgets-not-server-settings/', title: 'Mailbox budgets, not server settings' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<!-- Builder canvas styling: dot grid, connectors, wait + branch pills,
|
||||
and a gentle staggered reveal on the nodes. -->
|
||||
<style>
|
||||
.builder-canvas {
|
||||
background-image: radial-gradient(circle, #e2e9f3 1px, transparent 1px);
|
||||
background-size: 22px 22px;
|
||||
background-position: -1px -1px;
|
||||
}
|
||||
.flow-conn { display: flex; flex-direction: column; align-items: center; }
|
||||
.flow-line { width: 1.5px; height: 1rem; background: var(--border-strong); }
|
||||
.flow-wait {
|
||||
display: inline-flex; align-items: center; gap: 0.3rem;
|
||||
margin-block: 0.25rem; font-size: 11px; color: #94a3b8;
|
||||
}
|
||||
.flow-branch {
|
||||
display: inline-flex; align-items: center;
|
||||
padding: 1px 0.5rem; border-radius: 9999px;
|
||||
font-size: 11px; font-weight: 500; line-height: 18px;
|
||||
}
|
||||
.flow-branch-then { background: #ecfdf5; color: #047857; box-shadow: inset 0 0 0 1px rgb(167 243 208 / 0.8); }
|
||||
.flow-branch-else { background: #fff; color: #64748b; box-shadow: inset 0 0 0 1px #e2e8f0; }
|
||||
.flow-card {
|
||||
display: flex; align-items: center; gap: 0.65rem;
|
||||
height: 3.5rem; padding: 0 2rem 0 0.65rem; border-radius: 0.75rem;
|
||||
background: #fff; border: 1px solid rgb(226 232 240 / 0.9);
|
||||
box-shadow: 0 1px 2px rgba(15,23,42,0.04);
|
||||
}
|
||||
.flow-tile {
|
||||
display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
width: 2rem; height: 2rem; border-radius: 0.5rem;
|
||||
box-shadow: inset 0 0 0 1px var(--tw-ring-color, #e2e8f0);
|
||||
}
|
||||
.flow-kicker { color: #94a3b8; }
|
||||
.flow-title { font-size: 13px; font-weight: 500; line-height: 1.25rem; color: #0f172a; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.flow-sub { font-size: 11.5px; line-height: 1rem; color: #64748b; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.flow-add {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 22px; height: 22px; border-radius: 9999px;
|
||||
border: 1px solid #e2e8f0; background: #fff; color: #94a3b8; font-size: 13px; line-height: 1;
|
||||
}
|
||||
|
||||
.flow-node { animation: flowReveal 0.5s cubic-bezier(0.22, 1, 0.36, 1) both; animation-delay: calc(var(--i, 0) * 90ms); }
|
||||
@keyframes flowReveal { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.flow-node { animation: none; opacity: 1; transform: none; }
|
||||
}
|
||||
</style>
|
||||
<CTA photo="paint-aqua" title="Build your first sequence in five minutes." description="Connect a mailbox, add your contacts and draw the first step. Warmup keeps running while you write." />
|
||||
</Layout>
|
||||
|
||||
+151
-829
File diff suppressed because it is too large
Load Diff
+147
-383
@@ -1,415 +1,184 @@
|
||||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import HeroAtmosphere from '../components/HeroAtmosphere.astro';
|
||||
import Icon from '../components/Icon.astro';
|
||||
import CTA from '../components/CTA.astro';
|
||||
import PillarPosts from '../components/PillarPosts.astro';
|
||||
import SectionIntro from '../components/page/SectionIntro.astro';
|
||||
import Rule from '../components/page/Rule.astro';
|
||||
import Faq from '../components/page/Faq.astro';
|
||||
import WorkdayScene from '../components/scenes/sending/WorkdayScene.astro';
|
||||
import RangesScene from '../components/scenes/sending/RangesScene.astro';
|
||||
import ClocksScene from '../components/scenes/sending/ClocksScene.astro';
|
||||
import PathScene from '../components/scenes/sending/PathScene.astro';
|
||||
import VolumeScene from '../components/scenes/sending/VolumeScene.astro';
|
||||
|
||||
// Single-send trace
|
||||
const trace = [
|
||||
{ t: 'T+0ms', stage: 'queue', actor: 'backend', body: 'campaign_scheduler picks recipient · checks per-mailbox cap (47/100) + gap (last send 11m ago > 10m)' },
|
||||
{ t: 'T+4ms', stage: 'kafka', actor: 'backend →', body: 'publishes send command to worker.commands.w-01 with message envelope + mailbox creds reference' },
|
||||
{ t: 'T+8ms', stage: 'consume', actor: '→ worker', body: 'worker w-01 consumes command, fetches OAuth refresh token from KMS-cached DEK, builds RFC 5322 message' },
|
||||
{ t: 'T+121ms', stage: 'oauth', actor: 'provider', body: 'refresh access token from Google identity service' },
|
||||
{ t: 'T+312ms', stage: 'smtp send', actor: 'gmail api', body: 'POST users.messages.send → 200 OK · returns gmail message id 18f3a2b9c1' },
|
||||
{ t: 'T+318ms', stage: 'ack', actor: '← worker', body: 'worker publishes send-result event to worker.results.w-01 · cap usage incremented to 48/100' },
|
||||
{ t: 'T+326ms', stage: 'persist', actor: 'consumer', body: 'consumer folds result into campaign state · contact moves to "sent" status' },
|
||||
// Behaviour from docs/content/docs/guides/sending-behavior.mdx, campaigns.mdx
|
||||
// and the worker topology in CLAUDE.md.
|
||||
|
||||
const heroStats = [
|
||||
{ v: '28 to 32', k: 'cold emails a day' },
|
||||
{ v: '5 to 9', k: 'an hour, at most' },
|
||||
{ v: '90 to 420s', k: 'between two emails' },
|
||||
];
|
||||
|
||||
const scenarios = [
|
||||
{
|
||||
label: 'Founder · 1 mailbox',
|
||||
mailboxes: 1,
|
||||
workers: 1,
|
||||
daily: 100,
|
||||
note: 'A single ben@acme.com runs on one worker. Default 100/day cap. Plenty of headroom on the worker for warmup partners.',
|
||||
},
|
||||
{
|
||||
label: 'Sales team · 10 mailboxes',
|
||||
mailboxes: 10,
|
||||
workers: 1,
|
||||
daily: 1000,
|
||||
featured: true,
|
||||
note: 'Right at the per-worker concentration warning. One worker carries all 10 mailboxes; healthy as long as no mailbox drifts.',
|
||||
},
|
||||
{
|
||||
label: 'Agency · 50 mailboxes',
|
||||
mailboxes: 50,
|
||||
workers: 5,
|
||||
daily: 5000,
|
||||
note: 'Spread across 5 workers so each holds ~10. Concentration warnings never fire. Adding mailboxes triggers a new worker, not a denser one.',
|
||||
},
|
||||
const ranges = [
|
||||
['Working days', 'Monday to Friday, for warmup and cold mail alike'],
|
||||
['Start of the day', 'Somewhere between 09:03 and 09:27'],
|
||||
['End of the day', 'Somewhere between 17:18 and 17:56'],
|
||||
['Lunch break', 'Starts between 12:00 and 13:30, lasts 30 to 60 minutes'],
|
||||
];
|
||||
|
||||
const providers = [
|
||||
{ name: 'Google Workspace', tag: 'Gmail · app password', body: 'A guided setup over smtp.gmail.com and imap.gmail.com, no Google app review in the way. Threaded replies preserved via In-Reply-To and References. Postmaster Tools surfaced where the domain is enrolled.', accent: '#ea4335' },
|
||||
{ name: 'Microsoft 365', tag: 'Outlook · OAuth', body: 'Graph API for send with shared-mailbox support. IMAP and EWS for sync. Tenant-scoped admin consent.', accent: '#0078d4' },
|
||||
{ name: 'Custom SMTP / IMAP',tag: 'BYOS · TLS', body: 'Bring your own server. TLS, STARTTLS, app passwords, and OAuth where the provider supports it. iCloud and Zoho work first-class.', accent: '#0284c7' },
|
||||
const timezones = [
|
||||
['1', 'The contact’s own timezone field, when it holds a real zone such as America/Denver'],
|
||||
['2', 'The country of their email domain: acme.de reads as Berlin, acme.co.uk as London'],
|
||||
['3', 'The fallback timezone you choose, for everyone else'],
|
||||
];
|
||||
|
||||
const faq = [
|
||||
['How does worker assignment happen?', 'Mailboxes are pinned to a worker on first connect and stay there for stability. The control plane can rebalance live by moving the mailbox to a different worker without dropping campaigns or warmup.'],
|
||||
['What happens if a worker crashes?', 'Worker state is disposable. A replacement worker reads pending commands from the same Kafka topic on boot and resumes. No mailbox is stuck waiting on a single host.'],
|
||||
['Can a single mailbox use multiple IPs?', 'No. A mailbox lives on one worker with one IP at a time so receiver reputation can stabilise. Migration changes the IP intentionally and is logged.'],
|
||||
['How are bursts prevented?', 'The per-mailbox 600-second gap is enforced server-side. Even if a campaign queues 100 sends at once, dispatch is rate-limited to the gap, not the queue size.'],
|
||||
const path = [
|
||||
{ t: 'Signed in, not relayed', d: 'Google mailboxes send through the Gmail API, Microsoft mailboxes through Microsoft Graph, and every other host over SMTP.' },
|
||||
{ t: 'A familiar address', d: 'Each mailbox stays on the same worker wherever it can, and moves only when that worker goes down, is throttled or runs far over capacity.' },
|
||||
{ t: 'An address of your own', d: 'On Business and above, your mailboxes sign in from an address no other customer uses.' },
|
||||
];
|
||||
|
||||
const limits = [
|
||||
{ v: '50', k: 'cold emails a day per mailbox by default, and the dashboard warns past 100' },
|
||||
{ v: '10 min', k: 'between two sends from one mailbox on a fixed schedule' },
|
||||
{ v: '~30', k: 'a day on a newly connected mailbox’s rolled plan' },
|
||||
{ v: '5', k: 'cold emails a day at most while a mailbox is still warming' },
|
||||
];
|
||||
|
||||
const faq: [string, string][] = [
|
||||
['Why randomise sending at all?', 'A mailbox that sends exactly 40 emails at exactly 10-minute intervals from 09:00:00 sharp looks like a machine. Varying the workday, the breaks and the spacing makes it look like the person whose mailbox it is.'],
|
||||
['Can this make a mailbox send more than its cap?', 'No. The workday can only lower volume or delay a send. A mailbox capped at 50 that rolls 31 for the day sends at most 31.'],
|
||||
['Does warmup follow the same workday?', 'Yes. Warmup keeps the same days, hours and lunch break, so warmup mail never goes out at 3am from a mailbox that keeps office hours. It has its own ramp and doesn’t eat into the cold budget.'],
|
||||
['Can I send in each recipient’s timezone?', 'Yes. Send-time optimization holds each email until the recipient’s local clock reaches the hours you choose. It reads the contact’s timezone field first, then the country of their email domain, then a fallback you set.'],
|
||||
['Do my mailboxes send from your servers?', 'Your mailboxes send through their own providers, Google, Microsoft or your SMTP host. Warmbly’s workers sign in to those providers for you, and each mailbox is kept on the same worker wherever possible, because a stable sign-in address is what providers trust.'],
|
||||
['Does the worker’s IP address affect my deliverability?', 'Recipients never see it. Your provider delivers the email from its own servers, so spam filters judge your mailbox and your domain. The worker’s address only matters to your provider when it signs in, which is why mailboxes are not moved between workers without a reason.'],
|
||||
];
|
||||
---
|
||||
<Layout
|
||||
title="Distributed Sending Engine | Warmbly"
|
||||
description="A distributed sending engine that spreads cold mail across many mailboxes, many workers and many IPs. Per-mailbox safety caps baked in."
|
||||
title="Cold Email Sending That Looks Human | Warmbly"
|
||||
description="Give every mailbox a human workday: randomised start and finish times, a lunch break, varied spacing and hourly limits in its own timezone, with per-mailbox caps and recipient-timezone sending."
|
||||
>
|
||||
<!-- ============================================================
|
||||
HERO · HeroAtmosphere, matches home / pricing / warmup / campaigns
|
||||
============================================================ -->
|
||||
<section class="relative isolate overflow-hidden">
|
||||
<HeroAtmosphere />
|
||||
|
||||
<div class="container-page relative pt-16 md:pt-24 pb-44 md:pb-56 text-center">
|
||||
<div class="inline-flex items-center gap-2 h-7 pl-1 pr-3 rounded-full bg-white/15 backdrop-blur ring-1 ring-white/25 text-[12px] text-white/95 shadow-[0_4px_14px_-4px_rgba(0,0,0,0.25)]">
|
||||
<span class="inline-flex items-center h-5 px-1.5 rounded-full text-[10.5px] font-semibold uppercase tracking-[0.06em] bg-white" style="color:#0369a1;">
|
||||
Sending engine
|
||||
</span>
|
||||
Distributed across many machines, many IPs
|
||||
</div>
|
||||
|
||||
<h1 class="mt-8 md:mt-10 text-[44px] sm:text-6xl md:text-[72px] lg:text-[80px] font-semibold tracking-[-0.04em] leading-[0.98] text-white max-w-4xl mx-auto">
|
||||
Many mailboxes.<br/>Many workers. Many IPs.
|
||||
</h1>
|
||||
<p class="mt-6 text-[17px] md:text-[19px] text-white/80 max-w-2xl mx-auto leading-relaxed">
|
||||
Not a glorified SMTP relay. The control plane decides what to send. Workers spread sends across machines and IPs so no single point becomes a reputation liability.
|
||||
</p>
|
||||
|
||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||
<a href="https://app.warmbly.com/auth/register" class="inline-flex h-11 px-5 items-center gap-2 rounded-[10px] text-[14.5px] font-semibold bg-white hover:bg-white hover:-translate-y-0.5 hover:shadow-[0_12px_28px_-6px_rgba(0,0,0,0.3)] transition-all duration-200 ease-out shadow-[0_4px_14px_-2px_rgba(0,0,0,0.18)]">
|
||||
<span style="color:#075985;">Connect a mailbox</span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#075985" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="#anatomy" class="inline-flex h-11 px-5 items-center rounded-[10px] text-[14.5px] font-medium bg-white/10 backdrop-blur ring-1 ring-white/40 text-white hover:bg-white/20 hover:-translate-y-0.5 transition-all duration-200 ease-out">
|
||||
See the architecture
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<!-- ============================================================
|
||||
ANATOMY · big architecture diagram as the centerpiece
|
||||
============================================================ -->
|
||||
<section id="anatomy" class="border-b border-[color:var(--border)] bg-white py-20 md:py-28 scroll-mt-4">
|
||||
<div class="container-page">
|
||||
<div class="max-w-3xl mb-12">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] font-mono text-[#0284c7] mb-3">Anatomy</div>
|
||||
<h2 class="text-[32px] md:text-[44px] font-semibold tracking-[-0.03em] leading-[1.05] text-heading">
|
||||
The engine, end to end.
|
||||
</h2>
|
||||
<p class="mt-4 text-[15.5px] text-foreground/70 leading-relaxed">
|
||||
Every component, every link. The backend never talks to a mailbox provider directly. Workers never read from Postgres. Each plane has one job and stays in its lane.
|
||||
<!-- HERO: copy beside the mailbox's Sending tab, today's workday open. -->
|
||||
<section class="container-page pt-14 md:pt-20">
|
||||
<div class="grid items-center gap-12 lg:grid-cols-[0.85fr_1.15fr] lg:gap-14">
|
||||
<div>
|
||||
<div class="text-[14px] text-slate-500">Sending</div>
|
||||
<h1 class="mt-4 text-[40px] sm:text-[52px] md:text-[60px] font-medium tracking-[-0.05em] leading-[1.02] text-slate-900">
|
||||
Every mailbox keeps a workday, like the person who owns it.
|
||||
</h1>
|
||||
<p class="mt-6 max-w-xl text-[17px] md:text-[18px] leading-[1.55] text-slate-500">
|
||||
Each mailbox starts at a slightly different time every day, takes a lunch break, spaces its emails unevenly and stops before evening, all in its own timezone. Volume comes from more mailboxes, never from pushing one harder.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Big architecture SVG -->
|
||||
<div class="rounded-[14px] bg-[#0c1224] ring-1 ring-white/10 overflow-hidden">
|
||||
<div class="px-6 py-3 border-b border-white/10 flex items-center justify-between text-[10.5px] font-mono text-white/55">
|
||||
<span>architecture · v3</span>
|
||||
<span>backend → kafka → workers → providers → receivers</span>
|
||||
<div class="mt-8 flex flex-wrap items-center gap-3">
|
||||
<a href="https://app.warmbly.com/auth/register" class="inline-flex h-11 items-center gap-2 rounded-full bg-slate-900 px-5 text-[15px] font-medium text-white transition-colors hover:bg-slate-800">
|
||||
Start free <Icon name="arrowRight" size={14} />
|
||||
</a>
|
||||
<a href="https://docs.warmbly.com/guides/sending-behavior/" class="inline-flex h-11 items-center rounded-full border border-slate-200 px-5 text-[15px] text-slate-800 transition-colors hover:border-slate-300 hover:bg-slate-50">
|
||||
Read the guide
|
||||
</a>
|
||||
</div>
|
||||
<div class="p-6 md:p-10">
|
||||
<svg viewBox="0 0 1000 440" class="w-full h-auto">
|
||||
<defs>
|
||||
<linearGradient id="archLine" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#7dd3fc" stop-opacity="0.7"/>
|
||||
<stop offset="100%" stop-color="#7dd3fc" stop-opacity="0.2"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Lane labels -->
|
||||
<text x="100" y="24" fill="#7dd3fc" font-size="10" font-family="ui-monospace,monospace" letter-spacing="2">CONTROL PLANE</text>
|
||||
<text x="500" y="24" fill="#7dd3fc" font-size="10" font-family="ui-monospace,monospace" letter-spacing="2">EXECUTION PLANE</text>
|
||||
<text x="820" y="24" fill="#7dd3fc" font-size="10" font-family="ui-monospace,monospace" letter-spacing="2">RECEIVERS</text>
|
||||
|
||||
<!-- Backend -->
|
||||
<rect x="40" y="50" width="180" height="60" rx="8" fill="rgba(255,255,255,0.05)" stroke="#7dd3fc" stroke-opacity="0.45"/>
|
||||
<text x="130" y="75" text-anchor="middle" fill="white" font-size="13" font-weight="600">Backend (REST)</text>
|
||||
<text x="130" y="92" text-anchor="middle" fill="#94a3b8" font-size="10" font-family="ui-monospace,monospace">cmd/backend</text>
|
||||
|
||||
<!-- Postgres -->
|
||||
<rect x="40" y="140" width="180" height="50" rx="8" fill="rgba(255,255,255,0.05)" stroke="#7dd3fc" stroke-opacity="0.3"/>
|
||||
<text x="130" y="162" text-anchor="middle" fill="white" font-size="12" font-weight="600">Postgres</text>
|
||||
<text x="130" y="178" text-anchor="middle" fill="#94a3b8" font-size="10" font-family="ui-monospace,monospace">campaigns · contacts · auth</text>
|
||||
|
||||
<!-- Consumer -->
|
||||
<rect x="40" y="220" width="180" height="50" rx="8" fill="rgba(255,255,255,0.05)" stroke="#7dd3fc" stroke-opacity="0.3"/>
|
||||
<text x="130" y="242" text-anchor="middle" fill="white" font-size="12" font-weight="600">Consumer</text>
|
||||
<text x="130" y="258" text-anchor="middle" fill="#94a3b8" font-size="10" font-family="ui-monospace,monospace">cmd/consumer</text>
|
||||
|
||||
<!-- Kafka spine -->
|
||||
<rect x="290" y="50" width="100" height="340" rx="8" fill="rgba(14,165,233,0.08)" stroke="#0ea5e9" stroke-opacity="0.5"/>
|
||||
<text x="340" y="78" text-anchor="middle" fill="#7dd3fc" font-size="11" font-family="ui-monospace,monospace" letter-spacing="1.5">KAFKA</text>
|
||||
<text x="340" y="120" text-anchor="middle" fill="#cbd5e1" font-size="10" font-family="ui-monospace,monospace">worker.commands.*</text>
|
||||
<text x="340" y="140" text-anchor="middle" fill="#cbd5e1" font-size="10" font-family="ui-monospace,monospace">worker.results.*</text>
|
||||
<text x="340" y="160" text-anchor="middle" fill="#cbd5e1" font-size="10" font-family="ui-monospace,monospace">tracking.events</text>
|
||||
<text x="340" y="180" text-anchor="middle" fill="#cbd5e1" font-size="10" font-family="ui-monospace,monospace">deliverability</text>
|
||||
|
||||
<!-- Workers column -->
|
||||
{[80, 170, 260, 350].map((y, i) => (
|
||||
<g>
|
||||
<rect x="450" y={y} width="200" height="60" rx="8" fill="rgba(255,255,255,0.05)" stroke="#7dd3fc" stroke-opacity="0.35"/>
|
||||
<circle cx="467" cy={y + 18} r="3" fill="#10b981"/>
|
||||
<text x="482" y={y + 21} fill="white" font-size="12" font-weight="600" font-family="ui-monospace,monospace">worker-{String(i + 1).padStart(2, '0')}</text>
|
||||
<text x="467" y={y + 38} fill="#94a3b8" font-size="10" font-family="ui-monospace,monospace">192.0.2.{14 + i * 14} · {7 + i} mailboxes</text>
|
||||
<text x="467" y={y + 52} fill="#64748b" font-size="9" font-family="ui-monospace,monospace">+ Redis · KMS · S3</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
<!-- Providers -->
|
||||
{[
|
||||
{ y: 80, name: 'Gmail API', color: '#ea4335' },
|
||||
{ y: 170, name: 'Graph API', color: '#0078d4' },
|
||||
{ y: 260, name: 'SMTP / IMAP', color: '#0ea5e9' },
|
||||
{ y: 350, name: 'iCloud / Zoho', color: '#7c3aed' },
|
||||
].map((p, i) => (
|
||||
<g>
|
||||
<rect x="730" y={p.y} width="220" height="60" rx="8" fill="rgba(255,255,255,0.04)" stroke="#7dd3fc" stroke-opacity="0.25"/>
|
||||
<circle cx="747" cy={p.y + 18} r="3" fill={p.color}/>
|
||||
<text x="762" y={p.y + 21} fill="white" font-size="12" font-weight="600" font-family="ui-monospace,monospace">{p.name}</text>
|
||||
<text x="747" y={p.y + 38} fill="#94a3b8" font-size="10" font-family="ui-monospace,monospace">OAuth · refresh tokens</text>
|
||||
<text x="747" y={p.y + 52} fill="#64748b" font-size="9" font-family="ui-monospace,monospace">→ Gmail · Outlook · receivers</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
<!-- Lines: backend/consumer ↔ kafka -->
|
||||
<line x1="220" y1="80" x2="290" y2="120" stroke="url(#archLine)" stroke-width="1.5"/>
|
||||
<line x1="220" y1="245" x2="290" y2="220" stroke="url(#archLine)" stroke-width="1.5"/>
|
||||
<line x1="220" y1="165" x2="290" y2="220" stroke="#7dd3fc" stroke-opacity="0.2" stroke-width="1" stroke-dasharray="3 4"/>
|
||||
|
||||
<!-- Lines: kafka ↔ workers -->
|
||||
{[110, 200, 290, 380].map((y, i) => (
|
||||
<g>
|
||||
<line x1="390" y1={200} x2="450" y2={y} stroke="url(#archLine)" stroke-width="1.5"/>
|
||||
<circle r="2.5" fill="#7dd3fc" opacity="0.95">
|
||||
<animateMotion dur={`${3 + i * 0.3}s`} repeatCount="indefinite" begin={`${i * 0.5}s`}
|
||||
path={`M 390 200 L 450 ${y}`}/>
|
||||
</circle>
|
||||
</g>
|
||||
))}
|
||||
|
||||
<!-- Lines: workers → providers -->
|
||||
{[110, 200, 290, 380].map((y) => (
|
||||
<g>
|
||||
<line x1="650" y1={y} x2="730" y2={y} stroke="#7dd3fc" stroke-opacity="0.4" stroke-width="1.5"/>
|
||||
<circle r="2.5" fill="#0ea5e9">
|
||||
<animateMotion dur="2.5s" repeatCount="indefinite"
|
||||
path={`M 650 ${y} L 730 ${y}`}/>
|
||||
</circle>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Annotation legend -->
|
||||
<div class="mt-6 grid sm:grid-cols-2 lg:grid-cols-3 gap-4 text-[12.5px]">
|
||||
{[
|
||||
{ c: '#7dd3fc', t: 'Control plane', d: 'Owns Postgres. Decides assignment. Never sends mail.' },
|
||||
{ c: '#0ea5e9', t: 'Kafka spine', d: 'Per-worker command + result topics. Replayable.' },
|
||||
{ c: '#10b981', t: 'Execution plane', d: 'Disposable workers. One IP each. No SQL connection.' },
|
||||
].map((l) => (
|
||||
<div class="flex items-baseline gap-3">
|
||||
<span class="w-2 h-2 rounded-full mt-1.5 shrink-0" style={`background: ${l.c}`}></span>
|
||||
<div>
|
||||
<div class="text-foreground font-semibold">{l.t}</div>
|
||||
<div class="text-foreground/60">{l.d}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================
|
||||
TRACE A SINGLE SEND · timeline with timestamps + actors
|
||||
============================================================ -->
|
||||
<section class="border-b border-[color:var(--border)] bg-[color:var(--surface-1)]/40 py-20 md:py-28">
|
||||
<div class="container-page">
|
||||
<div class="max-w-3xl mb-12">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] font-mono text-[#0284c7] mb-3">Trace a single send</div>
|
||||
<h2 class="text-[32px] md:text-[44px] font-semibold tracking-[-0.03em] leading-[1.05] text-heading">
|
||||
318ms from queue to 200 OK.
|
||||
</h2>
|
||||
<p class="mt-4 text-[15.5px] text-foreground/70 leading-relaxed">
|
||||
One message, every handoff. Latencies here are illustrative for a Gmail mailbox connected with Google sign-in, on a warm worker; one connected with an app password takes the SMTP path at the same points. Real traces are visible in the per-mailbox event view.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Trace timeline -->
|
||||
<div class="rounded-[14px] bg-white ring-1 ring-[color:var(--border)] overflow-hidden">
|
||||
<div class="px-6 py-3 border-b border-[color:var(--border)] bg-[color:var(--surface-1)]/60 flex items-center justify-between text-[10.5px] font-mono text-muted-foreground">
|
||||
<span>trace · msg_01HQX9F7P3A8KY2NJM4R6BWT0S</span>
|
||||
<span>mailbox: ben@acme.com · worker: w-01</span>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<!-- Vertical spine -->
|
||||
<div class="absolute left-[110px] top-6 bottom-6 w-px bg-[color:var(--border)]"></div>
|
||||
|
||||
{trace.map((s, i) => (
|
||||
<div class="grid grid-cols-[90px_30px_120px_1fr] gap-4 px-6 py-4 border-b border-[color:var(--border)] last:border-b-0 items-baseline">
|
||||
<div class="font-mono text-[11.5px] text-foreground/65">{s.t}</div>
|
||||
<div class="relative flex justify-center pt-1">
|
||||
<span class={`relative w-2.5 h-2.5 rounded-full ${i === trace.length - 1 ? 'bg-emerald-500' : 'bg-[#0284c7]'} ring-4 ring-white`}></span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[12px] font-mono uppercase tracking-[0.14em] text-foreground/55">{s.actor}</div>
|
||||
<div class="mt-0.5 text-[13.5px] font-semibold text-heading">{s.stage}</div>
|
||||
</div>
|
||||
<div class="text-[13px] text-foreground/70 leading-relaxed">{s.body}</div>
|
||||
<div class="mt-12 grid max-w-xl grid-cols-3 border-y border-slate-200">
|
||||
{heroStats.map((s, i) => (
|
||||
<div class={`py-4 ${i ? 'border-l border-slate-200 pl-4 md:pl-5' : 'pr-4'}`}>
|
||||
<p class="text-[19px] sm:text-[22px] md:text-[26px] font-light leading-none tracking-[-0.02em] text-slate-900 tabular-nums">{s.v}</p>
|
||||
<p class="mt-2 text-[12.5px] md:text-[13.5px] leading-snug text-slate-500">{s.k}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p class="mt-3 text-[13px] text-slate-400">The defaults for a newly connected mailbox. Change any of them.</p>
|
||||
</div>
|
||||
<WorkdayScene />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="px-6 py-3 border-t border-[color:var(--border)] bg-[color:var(--surface-1)]/60 flex flex-wrap items-center justify-between gap-2 text-[11.5px] font-mono">
|
||||
<span class="text-muted-foreground">total: 326ms · 7 handoffs · 1 provider call</span>
|
||||
<span class="inline-flex items-center gap-1.5 text-emerald-700">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
|
||||
delivered
|
||||
</span>
|
||||
<!-- RANGES: the form the day is rolled from. -->
|
||||
<Rule class="mt-20" />
|
||||
<section class="container-page py-20 md:py-28">
|
||||
<div class="grid items-center gap-12 lg:grid-cols-[1.15fr_1fr] lg:gap-16">
|
||||
<div class="order-2 lg:order-1"><RangesScene /></div>
|
||||
<div class="order-1 lg:order-2">
|
||||
<SectionIntro lead="Ranges, not fixed values." title="A new workday is rolled every morning." text="You set ranges once. Each day the mailbox picks its hours, break, target and spacing from them and keeps to that day, so a retry or a busy campaign never re-rolls it. Edits apply from the next local day." />
|
||||
<dl class="mt-8 max-w-xl divide-y divide-slate-200 border-y border-slate-200">
|
||||
{ranges.map(([k, v]) => (
|
||||
<div class="grid gap-1 py-3.5 sm:grid-cols-[150px_1fr] sm:gap-6">
|
||||
<dt class="text-[15px] font-medium text-slate-900">{k}</dt>
|
||||
<dd class="text-[15px] leading-[1.55] text-slate-500">{v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- THREE CLOCKS: send-time optimization, and where the calendars overlap. -->
|
||||
<Rule />
|
||||
<section class="container-page py-20 md:py-28">
|
||||
<div class="grid items-center gap-12 lg:grid-cols-[1fr_1.15fr] lg:gap-16">
|
||||
<div>
|
||||
<SectionIntro lead="Three clocks." title="An email goes out only when every clock agrees." text="The campaign has its sending window, the mailbox has its workday, and with send-time optimization on, the recipient has their own local hours. Warmbly waits for the moment all of them allow, and never sends outside them to catch up." />
|
||||
<div class="mt-8 max-w-xl">
|
||||
<div class="text-[13px] text-slate-400">How a recipient’s timezone is worked out</div>
|
||||
<ol class="mt-3 divide-y divide-slate-200 border-y border-slate-200">
|
||||
{timezones.map(([n, v]) => (
|
||||
<li class="flex gap-4 py-3.5">
|
||||
<span class="flex size-6 shrink-0 items-center justify-center rounded-full bg-slate-100 text-[12px] font-medium text-slate-600 tabular-nums">{n}</span>
|
||||
<span class="text-[15px] leading-[1.55] text-slate-600">{v}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
<ClocksScene />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================
|
||||
SCALING STORY · 3 progressive scenarios
|
||||
============================================================ -->
|
||||
<section class="border-b border-[color:var(--border)] py-20 md:py-28">
|
||||
<div class="container-page">
|
||||
<div class="max-w-3xl mb-12">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] font-mono text-[#0284c7] mb-3">Scaling</div>
|
||||
<h2 class="text-[32px] md:text-[44px] font-semibold tracking-[-0.03em] leading-[1.05] text-heading">
|
||||
Volume scales by adding mailboxes.
|
||||
</h2>
|
||||
<p class="mt-4 text-[15.5px] text-foreground/70 leading-relaxed">
|
||||
Not by cranking a few. Each scenario keeps every mailbox at the default cap. When one worker fills with mailboxes, the next worker takes the next batch.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-3 gap-4 lg:gap-5">
|
||||
{scenarios.map((s) => (
|
||||
<div class={`relative rounded-[14px] bg-white overflow-hidden ${s.featured ? 'ring-2 ring-[#0284c7]/35 shadow-[0_24px_48px_-20px_rgba(2,132,199,0.3)]' : 'ring-1 ring-[color:var(--border)]'}`}>
|
||||
<div class="h-1.5" style={`background: ${s.featured ? '#0284c7' : '#e6ebf2'}`}></div>
|
||||
<div class="p-6">
|
||||
<div class="text-[10.5px] uppercase tracking-[0.18em] font-mono text-muted-foreground">{s.label}</div>
|
||||
<div class="mt-4 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 pb-5 border-b border-[color:var(--border)]">
|
||||
<div>
|
||||
<div class="text-[22px] font-semibold tracking-[-0.02em] text-heading font-mono">{s.mailboxes}</div>
|
||||
<div class="mt-0.5 text-[10px] uppercase tracking-[0.16em] font-mono text-muted-foreground">mailboxes</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[22px] font-semibold tracking-[-0.02em] text-heading font-mono">{s.workers}</div>
|
||||
<div class="mt-0.5 text-[10px] uppercase tracking-[0.16em] font-mono text-muted-foreground">workers</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class={`text-[22px] font-semibold tracking-[-0.02em] font-mono ${s.featured ? 'text-[#0369a1]' : 'text-heading'}`}>{s.daily.toLocaleString()}</div>
|
||||
<div class="mt-0.5 text-[10px] uppercase tracking-[0.16em] font-mono text-muted-foreground">cold / day</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mailbox dot grid -->
|
||||
<div class="mt-4">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{Array.from({ length: Math.min(s.mailboxes, 50) }).map(() => (
|
||||
<span class={`w-2 h-2 rounded-sm ${s.featured ? 'bg-[#0284c7]' : 'bg-slate-400'}`} style="opacity:0.7"></span>
|
||||
))}
|
||||
{s.mailboxes > 50 && (
|
||||
<span class="text-[10px] font-mono text-muted-foreground self-center ml-1">+{s.mailboxes - 50}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-4 text-[12.5px] text-foreground/65 leading-relaxed">{s.note}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<!-- THE PATH: who actually delivers the email, wide. -->
|
||||
<Rule />
|
||||
<section class="container-page py-20 md:py-28">
|
||||
<div class="grid items-end gap-6 lg:grid-cols-[1.1fr_1fr] lg:gap-16">
|
||||
<SectionIntro lead="The path." title="Your provider delivers your email, not ours." />
|
||||
<p class="text-[16px] md:text-[18px] leading-[1.6] text-slate-500">Warmbly’s workers sign in to your mailbox at Google, Microsoft or your SMTP host and hand the email over. Your provider delivers it from its own servers, so recipients judge your mailbox and your domain, not our infrastructure.</p>
|
||||
</div>
|
||||
<div class="mt-12 md:mt-14">
|
||||
<PathScene />
|
||||
</div>
|
||||
<div class="mt-10 grid gap-8 md:grid-cols-3 md:gap-10">
|
||||
{path.map((p) => (
|
||||
<div class="border-t border-slate-200 pt-5">
|
||||
<h3 class="text-[17px] font-medium tracking-[-0.01em] text-slate-900">{p.t}</h3>
|
||||
<p class="mt-2 text-[15px] leading-[1.6] text-slate-500">{p.d}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================
|
||||
PROVIDERS · compact
|
||||
============================================================ -->
|
||||
<section class="border-b border-[color:var(--border)] bg-[color:var(--surface-1)]/40 py-20 md:py-28">
|
||||
<div class="container-page">
|
||||
<div class="max-w-2xl mb-10">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] font-mono text-[#0284c7] mb-3">Providers</div>
|
||||
<h2 class="text-[28px] md:text-[40px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading">
|
||||
Gmail in three steps. Native Outlook. SMTP for the rest.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-3 gap-4">
|
||||
{providers.map((p) => (
|
||||
<div class="rounded-[12px] bg-white ring-1 ring-[color:var(--border)] overflow-hidden">
|
||||
<div class="h-1" style={`background:${p.accent}`}></div>
|
||||
<div class="p-6">
|
||||
<div class="text-[10.5px] font-mono uppercase tracking-[0.18em] text-muted-foreground mb-2">{p.tag}</div>
|
||||
<div class="text-[16px] font-semibold text-heading">{p.name}</div>
|
||||
<p class="mt-2 text-[12.5px] text-foreground/65 leading-relaxed">{p.body}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<!-- VOLUME: a campaign's day is its mailboxes' days added up. -->
|
||||
<Rule />
|
||||
<section class="container-page py-20 md:py-28">
|
||||
<div class="grid items-center gap-12 lg:grid-cols-[1.15fr_1fr] lg:gap-16">
|
||||
<div class="order-2 lg:order-1"><VolumeScene /></div>
|
||||
<div class="order-1 lg:order-2">
|
||||
<SectionIntro lead="Volume." title="More mailboxes, never one mailbox pushed harder." text="Every mailbox has its own cap and its own workday, and a campaign’s day is the sum of them. A second healthy mailbox roughly doubles how fast a campaign works through its leads, while each one stays inside its own limits." />
|
||||
<a href="/blog/how-many-mailboxes-and-domains-cold-email/" class="group mt-6 inline-flex items-center gap-2 text-[16px] text-[color:var(--brand)] hover:text-[color:var(--brand-strong)]">
|
||||
How many mailboxes you need
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="transition-transform group-hover:translate-x-0.5"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-16 grid grid-cols-2 gap-px border-y border-slate-200 bg-slate-200 lg:grid-cols-4">
|
||||
{limits.map((l, i) => (
|
||||
<div class={`bg-white py-6 pr-5 ${i ? "pl-5" : ""}`}>
|
||||
<p class="text-[32px] md:text-[40px] font-light leading-none tracking-[-0.03em] text-slate-900 tabular-nums">{l.v}</p>
|
||||
<p class="mt-3 max-w-[30ch] text-[14px] leading-snug text-slate-500">{l.k}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================
|
||||
FAQ · grid-rows accordion
|
||||
============================================================ -->
|
||||
<section class="bg-white py-20 md:py-24">
|
||||
<div class="container-page grid lg:grid-cols-[1fr_1.8fr] gap-12 lg:gap-20 items-start">
|
||||
<div class="lg:sticky lg:top-24" style="align-self: start;">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] font-mono text-muted-foreground mb-3">FAQ</div>
|
||||
<h2 class="text-[28px] md:text-[36px] font-semibold tracking-[-0.025em] leading-[1.08] text-heading">
|
||||
Four engine questions.
|
||||
</h2>
|
||||
</div>
|
||||
<div class="divide-y divide-[color:var(--border)]" data-faq>
|
||||
{faq.map(([q, a]) => (
|
||||
<div class="faq-row py-2">
|
||||
<button type="button" class="faq-trigger group w-full flex items-start justify-between gap-4 py-3 text-left" aria-expanded="false">
|
||||
<span class="text-[15.5px] font-medium text-heading group-hover:text-[#0369a1] transition-colors">{q}</span>
|
||||
<span class="faq-icon shrink-0 inline-flex items-center justify-center w-7 h-7 rounded-full bg-[color:var(--surface-1)] ring-1 ring-[color:var(--border)] text-foreground/60 transition-transform duration-300 ease-out">
|
||||
<Icon name="plus" size={12} />
|
||||
</span>
|
||||
</button>
|
||||
<div class="faq-panel grid grid-rows-[0fr] transition-[grid-template-rows] duration-300 ease-out">
|
||||
<div class="overflow-hidden">
|
||||
<p class="pb-4 pr-12 text-[14px] text-foreground/75 leading-relaxed max-w-2xl">{a}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<script is:inline>
|
||||
(function () {
|
||||
const faq = document.querySelector('[data-faq]');
|
||||
if (!faq) return;
|
||||
faq.querySelectorAll('.faq-trigger').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
const row = btn.closest('.faq-row');
|
||||
const panel = row.querySelector('.faq-panel');
|
||||
const icon = btn.querySelector('.faq-icon');
|
||||
const open = btn.getAttribute('aria-expanded') === 'true';
|
||||
btn.setAttribute('aria-expanded', String(!open));
|
||||
panel.style.gridTemplateRows = open ? '0fr' : '1fr';
|
||||
if (icon) icon.style.transform = open ? 'rotate(0deg)' : 'rotate(45deg)';
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</section>
|
||||
<Rule />
|
||||
<Faq items={faq} title="Sending, answered." />
|
||||
|
||||
<PillarPosts
|
||||
title="Sending, in more depth"
|
||||
@@ -420,10 +189,5 @@ const faq = [
|
||||
]}
|
||||
/>
|
||||
|
||||
<CTA
|
||||
title="Connect a mailbox. Send your first campaign today."
|
||||
description="Connect Gmail with an app password or Outlook with one sign-in. Worker assignment is automatic."
|
||||
primaryLabel="Start free"
|
||||
primaryHref="https://app.warmbly.com/auth/register"
|
||||
/>
|
||||
<CTA photo="lake-motion" title="Give your mailboxes a human workday." description="Connect a mailbox and it starts with a sensible workday, ready to change whenever you like." />
|
||||
</Layout>
|
||||
|
||||
+152
-594
@@ -1,634 +1,194 @@
|
||||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import HeroAtmosphere from '../components/HeroAtmosphere.astro';
|
||||
import Icon from '../components/Icon.astro';
|
||||
import CTA from '../components/CTA.astro';
|
||||
import WarmupPoolNotice from '../components/WarmupPoolNotice.astro';
|
||||
import PillarPosts from '../components/PillarPosts.astro';
|
||||
import SectionIntro from '../components/page/SectionIntro.astro';
|
||||
import Rule from '../components/page/Rule.astro';
|
||||
import Faq from '../components/page/Faq.astro';
|
||||
import WarmupHeroScene from '../components/scenes/warmup/WarmupHeroScene.astro';
|
||||
import RampScene from '../components/scenes/warmup/RampScene.astro';
|
||||
import ConversationScene from '../components/scenes/warmup/ConversationScene.astro';
|
||||
import PlacementWatchScene from '../components/scenes/warmup/PlacementWatchScene.astro';
|
||||
import PoolStandingScene from '../components/scenes/warmup/PoolStandingScene.astro';
|
||||
import PoolsScene from '../components/scenes/warmup/PoolsScene.astro';
|
||||
|
||||
// All numbers below come from the Warmbly codebase / CLAUDE.md
|
||||
// (internal/scheduler/warmup_scheduler.go, internal/repository/pg_warmup.go,
|
||||
// internal/infrastructure/db/migrations/000156_seed_warmup_pools.up.sql).
|
||||
// Every number here is a product default from internal/config/constants.go,
|
||||
// docs/content/docs/guides/warmup.mdx and the warmup policy in CLAUDE.md.
|
||||
|
||||
const mail = [
|
||||
{
|
||||
t: 'A token for one inbox',
|
||||
b: 'Every warmup email carries a single-use token bound to the inbox it was sent to. It proves the copy arrived and records where it landed, then it is spent. Replayed or forwarded anywhere else, it counts for nothing.',
|
||||
},
|
||||
{
|
||||
t: 'Out of your way',
|
||||
b: 'By default, warmup mail in and out is filed into a folder called Warmbly, so it never buries your inbox or your Sent folder. It never shows up in the Warmbly unibox either.',
|
||||
},
|
||||
{
|
||||
t: 'Cleaned up for you',
|
||||
b: 'Warmup mail is deleted after 30 days by default. Set a shorter or longer window per mailbox, down to three days.',
|
||||
},
|
||||
];
|
||||
|
||||
const defaults = [
|
||||
{ k: 'Starting volume', v: '10 / day', why: 'Low enough to read as normal individual activity to any provider.' },
|
||||
{ k: 'Daily ramp', v: '+1 / day', why: 'Aggressive ramps trip volume-anomaly heuristics on the receiver.' },
|
||||
{ k: 'Volume ceiling', v: '40 / day', why: 'Maintains reputation without crowding the cold-campaign budget.' },
|
||||
{ k: 'Recent-partner gap', v: '7 days', why: 'Stops two mailboxes from forming a degenerate warmup pair.' },
|
||||
{ k: 'Token check', v: 'single-use', why: 'Every warmup message carries a token bound to its recipient, so nothing can be replayed or redirected.' },
|
||||
{ k: 'Re-entry probation', v: '20 deliveries', why: 'Enough fresh data to confirm health, not just dormancy.' },
|
||||
{ v: '10', k: 'emails on day one' },
|
||||
{ v: '+1', k: 'each day after' },
|
||||
{ v: '40', k: 'a day at most' },
|
||||
{ v: '30', k: 'days before warmup mail is deleted' },
|
||||
];
|
||||
|
||||
// Bands from CLAUDE.md "Recommended internal policy for shared paid pools".
|
||||
const bands = [
|
||||
{ name: 'Healthy', tone: 'emerald', spam: '< 10%', complaint: '< 0.03%', bounce: '< 1%', action: 'Default state. Continue ramp.' },
|
||||
{ name: 'Watch', tone: 'amber', spam: '≥ 10%', complaint: '≥ 0.03%', bounce: '—', action: 'Lower volume, increase spacing.' },
|
||||
{ name: 'Quarantined', tone: 'rose', spam: '≥ 20%', complaint: '≥ 0.10%', bounce: '≥ 5%', action: 'Removed from shared paid pool for 7 days.' },
|
||||
{ name: 'Blocked', tone: 'rose', spam: '≥ 40%', complaint: '≥ 0.30%', bounce: '≥ 10%', action: '30-day block. Review required for re-entry.' },
|
||||
const faq: [string, string][] = [
|
||||
['How long does warmup take?', 'Most new mailboxes need three to six weeks. At the default ramp a mailbox reaches 40 emails a day after about a month, and inbox placement usually settles over the same stretch.'],
|
||||
['Should warmup keep running once my campaigns start?', 'Yes. Warmup runs alongside cold sending at up to five messages a day, so a dip in reputation shows up in the warmup numbers and gets corrected before your campaigns feel it.'],
|
||||
['What gets a mailbox taken out of the pool?', 'High complaint or bounce rates: 0.10% complaints or 5% bounces pause it for 7 days, 0.30% or 10% for 30. Spam placement on its own never removes a mailbox; it only slows warmup down until placement recovers.'],
|
||||
['Do the free and premium pools mix?', 'Only on proven terms. A paying mailbox can also write to free mailboxes from other workspaces that have been healthy pool members for at least three days, and a free mailbox may only write back to a paying inbox that wrote to it in the last two weeks.'],
|
||||
['Which providers does placement count at?', 'Gmail, Outlook and Yahoo. They filter on sender reputation, which is what cold email is judged on. Smaller mail hosts run their own filters, so their spam folder is never held against you.'],
|
||||
['Can I warm up a self-hosted instance?', 'Yes, by linking it to Warmbly Cloud once it launches: up to 10 mailboxes free, or unlimited for $15 a month. Everything else stays on your own servers.'],
|
||||
];
|
||||
|
||||
const toneClass = (t: string) => {
|
||||
if (t === 'emerald') return { dot: 'bg-emerald-500', chip: 'bg-emerald-50 text-emerald-700' };
|
||||
if (t === 'amber') return { dot: 'bg-amber-500', chip: 'bg-amber-50 text-amber-700' };
|
||||
return { dot: 'bg-rose-500', chip: 'bg-rose-50 text-rose-700' };
|
||||
};
|
||||
|
||||
// Pool model from migration 000010 + pg_warmup.go.
|
||||
const pools = [
|
||||
{ name: 'Free pool', when: 'Free workspaces and linked self-hosted instances', features: ['Up to 10 linked mailboxes free', 'Same quarantine thresholds', 'Borrows proven paid mailboxes when thin'] },
|
||||
{ name: 'Premium pool', when: 'Hosted plans and the $15 pool plan', features: ['Vetted mailboxes only', 'Served first, always', 'Recovery pool for cooldown senders'], featured: true },
|
||||
{ name: 'Dedicated', when: 'Enterprise', features: ['Isolated worker + IP', 'Still participates in premium pool', 'Per-mailbox reputation, not infrastructure shortcut'] },
|
||||
];
|
||||
|
||||
const faq = [
|
||||
['How long does warmup take?', 'Plan on 3 to 6 weeks for a brand new mailbox to reach a stable warmed state. We ramp +1 / day from a 10 / day start with a 40 / day ceiling.'],
|
||||
['Do free and premium pools mix?', 'Only on proven terms. Paid mailboxes are always served from the premium pool first. When a tier runs thin, partner selection borrows the other tier\'s mailboxes that have been healthy members for at least three days, so nothing unproven ever reaches a paying mailbox. Dedicated workers still participate in the premium pool by default.'],
|
||||
['What triggers quarantine?', 'Complaint rate over 0.10%, spam-folder placement over 20% on at least 20 deliveries in 7 days, a bounce rate over 5%, or tampering with received warmup mail.'],
|
||||
['Can I keep warming after campaigns start?', 'Yes, and you should. Warmup runs in parallel with cold sending so any reputation dip is caught and offset before campaigns hit it.'],
|
||||
];
|
||||
|
||||
// Pre-compute the calendar grid in the frontmatter so JSX stays simple.
|
||||
// 5 weeks * 7 days. Business days (Mon-Fri) get a warmup volume that
|
||||
// ramps from 10 by +1 each business day. Weekends are no-send.
|
||||
const calendar = [];
|
||||
let bizDay = 0;
|
||||
for (let wk = 0; wk < 5; wk++) {
|
||||
const row = [];
|
||||
for (let dow = 0; dow < 7; dow++) {
|
||||
const calDay = wk * 7 + dow + 1;
|
||||
const isWeekend = dow >= 5;
|
||||
const visible = calDay <= 30;
|
||||
let volume = 0;
|
||||
if (visible && !isWeekend) {
|
||||
bizDay += 1;
|
||||
volume = Math.min(9 + bizDay, 40);
|
||||
}
|
||||
let bg = '#f1f5f9';
|
||||
let textColor = '#94a3b8';
|
||||
if (visible && !isWeekend) {
|
||||
if (volume >= 40) { bg = '#075985'; textColor = '#ffffff'; }
|
||||
else if (volume >= 30) { bg = '#0284c7'; textColor = '#ffffff'; }
|
||||
else if (volume >= 20) { bg = '#38bdf8'; textColor = '#0c4a6e'; }
|
||||
else if (volume >= 15) { bg = '#7dd3fc'; textColor = '#0c4a6e'; }
|
||||
else { bg = '#e0f2fe'; textColor = '#075985'; }
|
||||
} else if (visible && isWeekend) {
|
||||
bg = '#f8fafc';
|
||||
textColor = '#cbd5e1';
|
||||
}
|
||||
row.push({ calDay, isWeekend, visible, volume, bg, textColor });
|
||||
}
|
||||
calendar.push(row);
|
||||
}
|
||||
const sceneTitle = 'mt-6 text-[21px] md:text-[23px] tracking-[-0.02em] text-slate-900';
|
||||
const sceneBody = 'mt-2 max-w-xl text-[16px] md:text-[17px] leading-[1.55] text-slate-500';
|
||||
const link = 'group mt-6 inline-flex items-center gap-2 text-[16px] md:text-[17px] text-[color:var(--brand)] hover:text-[color:var(--brand-strong)]';
|
||||
---
|
||||
<Layout
|
||||
title="Email Warmup for Sender Reputation | Warmbly"
|
||||
description="Reputation-safe warmup pools. Verification tokens, gradual ramps, and quarantine bands stricter than provider enforcement."
|
||||
title="Email Warmup That Builds Sender Reputation | Warmbly"
|
||||
description="Warm up new mailboxes with real email in a vetted pool: 10 a day, adding one a day up to 40, with inbox placement tracked at Gmail, Outlook and Yahoo."
|
||||
>
|
||||
<!-- ============================================================
|
||||
HERO — HeroAtmosphere, same as home and pricing
|
||||
============================================================ -->
|
||||
<section class="relative isolate overflow-hidden">
|
||||
<HeroAtmosphere />
|
||||
|
||||
<div class="container-page relative pt-16 md:pt-24 pb-40 md:pb-52 text-center">
|
||||
<div class="inline-flex items-center gap-2 h-7 pl-1 pr-3 rounded-full bg-white/15 backdrop-blur ring-1 ring-white/25 text-[12px] text-white/95 shadow-[0_4px_14px_-4px_rgba(0,0,0,0.25)]">
|
||||
<span class="inline-flex items-center h-5 px-1.5 rounded-full text-[10.5px] font-semibold uppercase tracking-[0.06em] bg-white" style="color:#0369a1;">
|
||||
Warmup
|
||||
</span>
|
||||
Reputation-safe pools, hosted or self-hosted
|
||||
<!-- HERO: the claim on the left, the how on the right, then the Accounts
|
||||
page with a mailbox's Warmup tab open. -->
|
||||
<section class="container-page pt-14 md:pt-24">
|
||||
<div class="grid gap-8 lg:grid-cols-[1.35fr_1fr] lg:items-end lg:gap-16">
|
||||
<div>
|
||||
<div class="text-[14px] text-slate-500">Warmup</div>
|
||||
<h1 class="mt-4 text-[40px] sm:text-[52px] md:text-[64px] font-medium tracking-[-0.05em] leading-[1.02] text-slate-900">
|
||||
Email warmup that builds real sender reputation.
|
||||
</h1>
|
||||
</div>
|
||||
<div class="lg:pb-2">
|
||||
<p class="text-[17px] md:text-[19px] leading-[1.55] text-slate-500">
|
||||
Warmbly warms each mailbox by trading a little real email with other inboxes, starting at 10 a day and adding one a day up to 40. It watches where that mail lands and slows a mailbox down the moment placement slips.
|
||||
</p>
|
||||
<div class="mt-8 flex flex-wrap items-center gap-3">
|
||||
<a href="https://app.warmbly.com/auth/register" class="inline-flex h-11 items-center gap-2 rounded-full bg-slate-900 px-5 text-[15px] font-medium text-white transition-colors hover:bg-slate-800">
|
||||
Start warming <Icon name="arrowRight" size={14} />
|
||||
</a>
|
||||
<a href="https://docs.warmbly.com/learn/email-warmup/" class="inline-flex h-11 items-center rounded-full border border-slate-200 px-5 text-[15px] text-slate-800 transition-colors hover:border-slate-300 hover:bg-slate-50">
|
||||
How warmup works
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-12 md:mt-16">
|
||||
<WarmupHeroScene />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<h1 class="mt-8 md:mt-10 text-[44px] sm:text-6xl md:text-[72px] lg:text-[80px] font-semibold tracking-[-0.04em] leading-[0.98] text-white max-w-4xl mx-auto">
|
||||
Warmup that earns<br/>reputation.
|
||||
</h1>
|
||||
<p class="mt-6 text-[17px] md:text-[19px] text-white/80 max-w-2xl mx-auto leading-relaxed">
|
||||
Mailboxes ramp gradually inside a shared pool of real mailboxes. Verification tokens distinguish real warmup from forged traffic. Bands quarantine before mailbox providers act. Self-hosting? When Warmbly Cloud launches, link your instance and we run it for you.
|
||||
</p>
|
||||
|
||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||
<a href="https://app.warmbly.com/auth/register" class="inline-flex h-11 px-5 items-center gap-2 rounded-[10px] text-[14.5px] font-semibold bg-white hover:bg-white hover:-translate-y-0.5 hover:shadow-[0_12px_28px_-6px_rgba(0,0,0,0.3)] transition-all duration-200 ease-out shadow-[0_4px_14px_-2px_rgba(0,0,0,0.18)]">
|
||||
<span style="color:#075985;">Start warming</span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#075985" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="https://docs.warmbly.com/learn/email-warmup/" class="inline-flex h-11 px-5 items-center rounded-[10px] text-[14.5px] font-medium bg-white/10 backdrop-blur ring-1 ring-white/40 text-white hover:bg-white/20 hover:-translate-y-0.5 transition-all duration-200 ease-out">
|
||||
How warmup works
|
||||
<!-- THE RAMP: copy beside the drawer's Analytics tab. -->
|
||||
<Rule class="mt-20 md:mt-28" />
|
||||
<section class="container-page py-20 md:py-28">
|
||||
<div class="grid items-center gap-10 lg:grid-cols-[0.9fr_1.1fr] lg:gap-16">
|
||||
<div>
|
||||
<SectionIntro lead="The ramp." title="Ten a day, one more each day, never past forty." text="Each mailbox follows its own ramp, spread across the warmup hours you set for it. The real number can come in lower than the ramp: a mailbox never writes to the same partner twice in a day, and its target is capped by how many partners are free." />
|
||||
<p class="mt-4 max-w-xl text-[16px] md:text-[18px] leading-[1.6] text-slate-500">
|
||||
If a warmup email lands in spam at Gmail, Outlook or Yahoo, the ramp stops climbing at once: today's target drops by a quarter for 48 hours and the daily increase pauses for three days, without catching up afterwards.
|
||||
</p>
|
||||
<a href="https://docs.warmbly.com/guides/warmup/" class={link}>
|
||||
Read about the ramp
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="transition-transform group-hover:translate-x-0.5"><polyline points="9 18 15 12 9 6" /></svg>
|
||||
</a>
|
||||
</div>
|
||||
<RampScene />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================
|
||||
THE RAMP · clean editorial inline section
|
||||
============================================================ -->
|
||||
<section class="border-b border-[color:var(--border)] py-20 md:py-28">
|
||||
<div class="container-page">
|
||||
<div class="grid lg:grid-cols-[1fr_1.4fr] gap-12 lg:gap-20 items-start">
|
||||
<div class="lg:sticky lg:top-24" style="align-self: start;">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] font-mono text-muted-foreground mb-3">The ramp</div>
|
||||
<h2 class="text-[28px] md:text-[40px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading">
|
||||
10 emails on day 1.<br/>40 emails on day 30.
|
||||
</h2>
|
||||
<p class="mt-4 text-[15px] text-foreground/70 leading-relaxed max-w-md">
|
||||
One additional warmup message each business day, until the mailbox reaches the 40 / day ceiling and holds. Aggressive ramps trip volume-anomaly heuristics on the receiver, so we keep it boring.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-px">
|
||||
{[
|
||||
{ stage: 'Day 1', value: '10', unit: '/ day', note: 'First warmup send. Normal individual activity to any provider.' },
|
||||
{ stage: 'Each day', value: '+1', unit: '/ day', note: 'Volume grows by one each business day. Weekends skipped.' },
|
||||
{ stage: 'Day 30', value: '40', unit: '/ day', note: 'Ceiling reached. Mailbox holds at 40 / day indefinitely.', featured: true },
|
||||
].map((r) => (
|
||||
<div class={`grid grid-cols-[120px_auto_1fr] gap-6 items-baseline py-5 border-b border-[color:var(--border)] last:border-b-0 ${r.featured ? 'text-[#0369a1]' : 'text-foreground'}`}>
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] font-mono text-muted-foreground">{r.stage}</div>
|
||||
<div class="flex items-baseline gap-1.5">
|
||||
<span class={`text-[44px] md:text-[56px] font-semibold tracking-[-0.035em] leading-none font-mono ${r.featured ? 'text-[#0369a1]' : 'text-heading'}`}>{r.value}</span>
|
||||
<span class="text-[13px] text-foreground/55">{r.unit}</span>
|
||||
</div>
|
||||
<p class="text-[13.5px] text-foreground/65 leading-relaxed">{r.note}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div class="pt-6 flex flex-wrap items-center gap-x-6 gap-y-1 text-[11.5px] font-mono text-muted-foreground">
|
||||
<span>~10m average gap between sends</span>
|
||||
<span class="w-1 h-1 rounded-full bg-[color:var(--border)]"></span>
|
||||
<span>Business hours by recipient TZ</span>
|
||||
<span class="w-1 h-1 rounded-full bg-[color:var(--border)]"></span>
|
||||
<span>Plain text only</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================
|
||||
VERIFICATION TOKEN ANATOMY · unique technical detail
|
||||
============================================================ -->
|
||||
<section class="border-y border-[color:var(--border)] bg-[color:var(--surface-1)]/40 py-20 md:py-24">
|
||||
<div class="container-page">
|
||||
<div class="max-w-3xl mb-12">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Verification token</div>
|
||||
<h2 class="text-[28px] md:text-[40px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading">
|
||||
Every warmup message carries proof it is real.
|
||||
</h2>
|
||||
<p class="mt-4 text-[15.5px] text-foreground/70 leading-relaxed">
|
||||
The single largest threat to a shared warmup pool is a sender pretending to receive its own warmup. A signed HMAC token embedded in the body, validated on classification, makes that impossible.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid lg:grid-cols-[1.2fr_1fr] gap-6">
|
||||
<!-- Token anatomy code -->
|
||||
<div class="rounded-[12px] overflow-hidden ring-1 ring-[color:var(--border)]">
|
||||
<div class="px-5 py-3 bg-[#0c1224] border-b border-white/10 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-[#ff5f56]/80"></span>
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-[#ffbd2e]/80"></span>
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-[#27c93f]/80"></span>
|
||||
</div>
|
||||
<span class="text-[11px] font-mono text-white/55">warmup token · embedded in body</span>
|
||||
</div>
|
||||
<pre class="bg-[#0c1224] text-white/85 p-5 text-[12.5px] leading-[1.65] font-mono overflow-x-auto"><code><span style="color:#94a3b8"># Token format · plain text, base64url</span>
|
||||
<span style="color:#7dd3fc">wbly</span>:<span style="color:#fde68a">01HQX9F7P3A8KY2NJM4R6BWT0S</span>:<span style="color:#fde68a">1746820392</span>:<span style="color:#a78bfa">3f9c2a8b5e1d740a</span>
|
||||
<span style="color:#64748b">└ pool id └ ulid msg id</span>
|
||||
<span style="color:#64748b">└ expires └ hmac-sha256</span>
|
||||
|
||||
<span style="color:#94a3b8"># Embedded as a normal-looking footer line:</span>
|
||||
<span style="color:#cbd5e1">Hey, just wanted to follow up on the project we discussed.</span>
|
||||
<span style="color:#cbd5e1">Let me know when you have a chance.</span>
|
||||
|
||||
<span style="color:#cbd5e1">— Ben</span>
|
||||
<span style="color:#64748b">ref: wbly:01HQX9F7P3A8KY2NJM4R6BWT0S:1746820392:3f9c2a8b</span>
|
||||
|
||||
<span style="color:#94a3b8"># Receiver classification → verify (HMAC + expiry + msg-id)</span>
|
||||
<span style="color:#7dd3fc">if</span> token.valid() && <span style="color:#f472b6">!seen</span>(token.id) <span style="color:#7dd3fc">then</span> credit_signal
|
||||
<span style="color:#7dd3fc">else</span> increment_invalid_count(sender)</code></pre>
|
||||
</div>
|
||||
|
||||
<!-- Token verification rules -->
|
||||
<div class="rounded-[12px] bg-white ring-1 ring-[color:var(--border)] overflow-hidden">
|
||||
<div class="px-5 py-3 border-b border-[color:var(--border)] text-[10.5px] uppercase tracking-[0.18em] font-mono text-muted-foreground">Validation rules</div>
|
||||
<div class="divide-y divide-[color:var(--border)]">
|
||||
{[
|
||||
{ rule: 'HMAC signature', why: 'Tokens are signed with a per-pool key. Forged tokens fail verification.' },
|
||||
{ rule: 'Expiry window', why: 'Tokens expire 48 hours after issue. Late replies do not count.' },
|
||||
{ rule: 'Single-use ID', why: 'Each token ULID is recorded on first valid use. Replays count as invalid.' },
|
||||
{ rule: 'Pool membership', why: 'Token pool ID must match the receiver\'s pool. Cross-pool tokens are rejected.' },
|
||||
{ rule: 'Sender / recipient', why: 'Token sender and recipient mailbox IDs must match the message routing.' },
|
||||
].map((r) => (
|
||||
<div class="px-5 py-3">
|
||||
<div class="text-[13px] font-semibold text-heading">{r.rule}</div>
|
||||
<p class="mt-1 text-[12.5px] text-foreground/65 leading-snug">{r.why}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div class="px-5 py-3 border-t border-[color:var(--border)] bg-[color:var(--surface-1)]/60 flex items-center justify-between text-[11.5px] font-mono">
|
||||
<span class="text-muted-foreground">≥ 20% spam placement →</span>
|
||||
<span class="inline-flex items-center gap-1 text-rose-700"><span class="w-1 h-1 rounded-full bg-rose-500"></span> mailbox quarantine</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================
|
||||
WHAT WARMUP DOES — editorial split, real product behavior
|
||||
============================================================ -->
|
||||
<section class="border-b border-[color:var(--border)] py-20 md:py-28">
|
||||
<div class="container-page grid lg:grid-cols-[1fr_2fr] gap-12 lg:gap-20 items-start">
|
||||
<div class="lg:sticky lg:top-24" style="align-self: start;">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">The daily loop</div>
|
||||
<h2 class="text-[28px] md:text-[40px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading">
|
||||
One loop. One mailbox. One day.
|
||||
</h2>
|
||||
<p class="mt-4 text-[15px] text-foreground/70 leading-relaxed max-w-md">
|
||||
Warmup is not a black box. Every step has a defined input and output, and every quarantine has a reason you can read.
|
||||
</p>
|
||||
<a href="https://docs.warmbly.com/learn/warmup-pools/" class="mt-6 inline-flex items-center gap-1.5 text-[13.5px] font-medium text-[#0369a1] hover:text-[#075985]">
|
||||
Read about warmup pools
|
||||
<Icon name="arrowRight" size={12} />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<ol class="divide-y divide-[color:var(--border)] border-y border-[color:var(--border)]">
|
||||
{[
|
||||
{ n: '01', t: 'Pool member selection', k: 'partner = pool.draw(exclude_recent: 7d)', b: 'A partner mailbox is drawn from the same pool the sender belongs to. Recent partners are excluded for 7 days to prevent degenerate pairs.' },
|
||||
{ n: '02', t: 'Token issue + embed', k: 'token = hmac(pool_key, sender:recipient:ulid:exp)', b: 'A signed token is generated for this pair and inlined as a `ref:` footer in the body. Receivers extract and validate it on classification.' },
|
||||
{ n: '03', t: 'Send through worker', k: 'worker.send(msg, schedule: business_hours(recipient_tz))', b: 'The mailbox\'s assigned worker dispatches the message during the recipient\'s business hours, with spacing that matches human typing cadence.' },
|
||||
{ n: '04', t: 'Partner classification', k: 'receiver.classify(msg) → {inbox|promotions|spam}', b: 'The receiving mailbox classifies the message and reports the result back along with the token. Inbox arrivals are credited as positive placement signals.' },
|
||||
{ n: '05', t: 'Score update + band', k: 'score = rolling_sum(placement, complaints, tokens)', b: 'The per-mailbox rolling score is updated. If thresholds are crossed, the mailbox transitions to Watch, Quarantined, or Blocked.' },
|
||||
].map((s) => (
|
||||
<li class="grid grid-cols-[44px_1fr] gap-4 py-5 items-baseline">
|
||||
<span class="font-mono text-[12px] text-[#0284c7] font-semibold">{s.n}</span>
|
||||
<div>
|
||||
<div class="text-[15.5px] font-semibold text-heading">{s.t}</div>
|
||||
<div class="mt-1 font-mono text-[12px] text-[#0369a1] bg-[color:var(--sky-1)]/60 px-2 py-1 rounded inline-block">{s.k}</div>
|
||||
<p class="mt-2 text-[14px] text-foreground/70 leading-relaxed">{s.b}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================
|
||||
WHAT WARMUP CHANGES · qualitative side-by-side
|
||||
============================================================ -->
|
||||
<section class="border-b border-[color:var(--border)] bg-[color:var(--surface-1)]/40 py-20 md:py-24">
|
||||
<div class="container-page">
|
||||
<div class="max-w-2xl mb-10">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Why warmup matters</div>
|
||||
<h2 class="text-[28px] md:text-[40px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading">
|
||||
A fresh mailbox is suspicious by default.
|
||||
</h2>
|
||||
<p class="mt-4 text-[15px] text-foreground/70 leading-relaxed">
|
||||
Gmail and Microsoft treat new senders cautiously. Without a behaviour history, a brand-new domain that immediately starts cold outreach looks like the worst-case sender. Warmup is the only way to build that history without a complaint event.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid lg:grid-cols-2 gap-4">
|
||||
<!-- Without warmup -->
|
||||
<div class="rounded-[14px] bg-white ring-1 ring-rose-100 overflow-hidden">
|
||||
<div class="px-6 py-3.5 bg-rose-50/60 border-b border-rose-100 flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full bg-rose-500"></span>
|
||||
<span class="text-[12px] uppercase tracking-[0.18em] font-mono text-rose-700 font-semibold">Without warmup</span>
|
||||
</div>
|
||||
<ul class="divide-y divide-[color:var(--border)]">
|
||||
{[
|
||||
'No sending history. Providers default to suspicion on first volume spike.',
|
||||
'Inbox vs spam classification has no positive signal to lean on.',
|
||||
'Any complaint or hard bounce moves reputation faster because the baseline is empty.',
|
||||
'Engagement signals (replies, moves from spam) take longer to accrue.',
|
||||
'First catastrophic event can hit within days of starting cold sends.',
|
||||
].map((b) => (
|
||||
<li class="px-6 py-3 flex items-start gap-3">
|
||||
<Icon name="warning" size={12} class="text-rose-500 mt-1 shrink-0" />
|
||||
<span class="text-[13.5px] text-foreground/80 leading-snug">{b}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- With warmup -->
|
||||
<div class="rounded-[14px] bg-white ring-1 ring-emerald-100 overflow-hidden">
|
||||
<div class="px-6 py-3.5 bg-emerald-50/60 border-b border-emerald-100 flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-500"></span>
|
||||
<span class="text-[12px] uppercase tracking-[0.18em] font-mono text-emerald-700 font-semibold">With Warmbly warmup</span>
|
||||
</div>
|
||||
<ul class="divide-y divide-[color:var(--border)]">
|
||||
{[
|
||||
'Gradual volume ramp matches what a real mailbox usage looks like.',
|
||||
'Inbox arrivals at vetted partners build a positive placement signal.',
|
||||
'Replies in the pool produce real engagement history at the receiver.',
|
||||
'Verification tokens prove the activity is genuine, not self-replied noise.',
|
||||
'Per-mailbox bands quarantine drift weeks before providers would act.',
|
||||
].map((b) => (
|
||||
<li class="px-6 py-3 flex items-start gap-3">
|
||||
<Icon name="check" size={12} class="text-emerald-600 mt-1 shrink-0" />
|
||||
<span class="text-[13.5px] text-foreground/80 leading-snug">{b}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-5 text-[12px] text-muted-foreground italic max-w-3xl">
|
||||
Reputation is per-mailbox, per-IP, and per-domain. Warmup builds all three simultaneously. We do not publish placement-improvement percentages because the answer depends on your domain age, content quality, list hygiene, and DMARC posture more than anything we control.
|
||||
<!-- WHAT WARMUP MAIL IS: a real thread in a partner's mailbox. -->
|
||||
<Rule />
|
||||
<section class="container-page py-20 md:py-28">
|
||||
<div class="grid gap-6 lg:grid-cols-[1.1fr_1fr] lg:items-end lg:gap-16">
|
||||
<SectionIntro lead="What warmup mail is." title="Short plain-text threads that say what they are." />
|
||||
<p class="max-w-xl text-[16px] md:text-[18px] leading-[1.6] text-slate-500 lg:pb-1">
|
||||
Each message is plain text with no tracking pixel and opens with "Simulated diagnostic", so nobody mistakes it for a real conversation. Partners write back on their own schedule, about three times in ten by default, and each thread closes after a few turns.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================
|
||||
HEALTH BANDS · horizontal threshold gauge (unique)
|
||||
============================================================ -->
|
||||
<section class="py-20 md:py-28">
|
||||
<div class="container-page">
|
||||
<div class="max-w-2xl mb-12">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Health bands</div>
|
||||
<h2 class="text-[32px] md:text-[44px] font-semibold tracking-[-0.03em] leading-[1.05] text-heading">
|
||||
Bands that act before providers do.
|
||||
</h2>
|
||||
<p class="mt-4 text-[15.5px] text-foreground/70 leading-relaxed">
|
||||
Spam placement on the X axis. Each band starts where the previous one ends. Warmbly's thresholds (in sky) sit well to the left of Google's and SES's published enforcement points (in rose) so we pull a mailbox out of the shared pool weeks before it would earn a public complaint.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- The gauge -->
|
||||
<div class="rounded-[14px] bg-white ring-1 ring-[color:var(--border)] p-7 md:p-10">
|
||||
<!-- Header -->
|
||||
<div class="flex items-baseline justify-between mb-7">
|
||||
<div class="text-[10.5px] uppercase tracking-[0.22em] font-mono text-muted-foreground">Spam placement · 0% → 100%</div>
|
||||
<div class="text-[10.5px] uppercase tracking-[0.22em] font-mono text-muted-foreground">Per-mailbox rolling window</div>
|
||||
<div class="mt-12">
|
||||
<ConversationScene />
|
||||
</div>
|
||||
<div class="mt-12 grid gap-10 md:grid-cols-3 md:gap-8">
|
||||
{mail.map((m) => (
|
||||
<div class="md:border-l md:border-slate-200 md:pl-6 md:first:border-l-0 md:first:pl-0">
|
||||
<h3 class="text-[19px] font-medium tracking-[-0.01em] text-slate-900">{m.t}</h3>
|
||||
<p class="mt-2 text-[15px] leading-[1.6] text-slate-500">{m.b}</p>
|
||||
</div>
|
||||
|
||||
<!-- The gauge bar with 4 bands -->
|
||||
<div class="relative h-12 rounded-[6px] overflow-hidden flex">
|
||||
<div class="flex-[10] bg-emerald-100 relative">
|
||||
<div class="absolute inset-0 flex items-center px-3">
|
||||
<span class="text-[11px] font-mono font-semibold text-emerald-700">HEALTHY · 0–10%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-[10] bg-amber-100 relative">
|
||||
<div class="absolute inset-0 flex items-center px-3">
|
||||
<span class="text-[11px] font-mono font-semibold text-amber-700">WATCH · 10–20%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-[20] bg-rose-100 relative">
|
||||
<div class="absolute inset-0 flex items-center px-3">
|
||||
<span class="text-[11px] font-mono font-semibold text-rose-700">QUARANTINED · 20–40%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-[60] relative" style="background: linear-gradient(90deg, #fda4af 0%, #9f1239 100%);">
|
||||
<div class="absolute inset-0 flex items-center px-3">
|
||||
<span class="text-[11px] font-mono font-semibold text-white">BLOCKED · 40%+</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tick marks below -->
|
||||
<div class="relative mt-1.5 h-3">
|
||||
{[0, 10, 20, 40, 80, 100].map((pct) => (
|
||||
<div class="absolute" style={`left: ${pct}%; transform: translateX(-50%);`}>
|
||||
<div class="w-px h-2 bg-[color:var(--border)] mx-auto"></div>
|
||||
<div class="text-[10px] font-mono text-muted-foreground mt-1 text-center">{pct}%</div>
|
||||
</div>
|
||||
))}
|
||||
<!-- Google + SES enforcement markers -->
|
||||
<div class="absolute" style="left: 80%; transform: translateX(-50%); top: 18px;">
|
||||
<div class="text-[10px] font-mono text-rose-700 font-semibold uppercase tracking-[0.14em]">↑ providers act</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Band detail rows -->
|
||||
<div class="mt-10 grid md:grid-cols-2 lg:grid-cols-4 gap-px bg-[color:var(--border)] rounded-[10px] overflow-hidden ring-1 ring-[color:var(--border)]">
|
||||
{bands.map((b) => {
|
||||
const t = toneClass(b.tone);
|
||||
return (
|
||||
<div class="bg-white p-5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class={`w-2 h-2 rounded-full ${t.dot}`}></span>
|
||||
<span class="text-[13px] font-semibold text-heading">{b.name}</span>
|
||||
</div>
|
||||
<div class="mt-4 space-y-1.5 text-[11.5px] font-mono">
|
||||
<div class="flex justify-between"><span class="text-muted-foreground">Spam</span><span class="text-heading font-semibold">{b.spam}</span></div>
|
||||
<div class="flex justify-between"><span class="text-muted-foreground">Complaint</span><span class="text-heading font-semibold">{b.complaint}</span></div>
|
||||
<div class="flex justify-between"><span class="text-muted-foreground">Bounce</span><span class="text-heading font-semibold">{b.bounce}</span></div>
|
||||
</div>
|
||||
<p class="mt-4 pt-4 border-t border-[color:var(--border)] text-[12.5px] text-foreground/70 leading-snug">{b.action}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p class="mt-5 text-[11.5px] text-muted-foreground italic">
|
||||
Spam-placement band requires at least 20 warmup deliveries in the past 7 days. Complaint band requires at least 100 delivered messages in the past 30 days.
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================
|
||||
POOL ISOLATION · 4 stacked surfaces as block-diagram cards
|
||||
============================================================ -->
|
||||
<section class="border-y border-[color:var(--border)] bg-[color:var(--surface-1)]/40 py-20 md:py-28">
|
||||
<div class="container-page">
|
||||
<div class="max-w-2xl mb-12">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Pool isolation</div>
|
||||
<h2 class="text-[28px] md:text-[40px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading">
|
||||
Four pools. Zero crossover.
|
||||
</h2>
|
||||
<p class="mt-4 text-[15px] text-foreground/70 leading-relaxed">
|
||||
Mailboxes never silently move between pools. A free-trial mailbox stays in the free pool. A quarantined paid mailbox drops into recovery, not back to premium. Dedicated infrastructure runs alongside without sharing IPs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 4 isolation cards in a 2x2 grid with a clear visual ranking -->
|
||||
<div class="grid md:grid-cols-2 gap-4 lg:gap-5">
|
||||
<!-- Free pool -->
|
||||
<div class="relative rounded-[14px] bg-white ring-1 ring-[color:var(--border)] overflow-hidden">
|
||||
<div class="h-1.5 bg-slate-300"></div>
|
||||
<div class="p-6">
|
||||
<div class="flex items-baseline justify-between mb-1">
|
||||
<div class="text-[18px] font-semibold tracking-[-0.02em] text-heading">Free pool</div>
|
||||
<div class="text-[10.5px] font-mono uppercase tracking-[0.16em] text-muted-foreground">Free workspace</div>
|
||||
</div>
|
||||
<div class="text-[12px] font-mono text-muted-foreground mb-4">trial mailboxes only</div>
|
||||
<ul class="space-y-2 text-[13px] text-foreground/75">
|
||||
<li class="flex items-start gap-2"><Icon name="check" size={12} class="text-slate-500 mt-1 shrink-0"/><span>Recipients drawn exclusively from other free-plan mailboxes.</span></li>
|
||||
<li class="flex items-start gap-2"><Icon name="check" size={12} class="text-slate-500 mt-1 shrink-0"/><span>Same auto-quarantine thresholds as premium.</span></li>
|
||||
<li class="flex items-start gap-2"><Icon name="check" size={12} class="text-slate-500 mt-1 shrink-0"/><span>Cannot send to premium-pool mailboxes.</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Premium pool · featured -->
|
||||
<div class="relative rounded-[14px] bg-white overflow-hidden ring-2 ring-[#0284c7]/35 shadow-[0_20px_40px_-20px_rgba(2,132,199,0.3)]">
|
||||
<div class="h-1.5 bg-[#0284c7]"></div>
|
||||
<div class="absolute top-1.5 right-4 inline-flex items-center h-5 px-2 rounded-b text-[10px] font-semibold uppercase tracking-[0.14em] font-mono bg-[#0284c7] text-white">
|
||||
Most paid plans
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="flex items-baseline justify-between mb-1">
|
||||
<div class="text-[18px] font-semibold tracking-[-0.02em] text-[#0369a1]">Premium pool</div>
|
||||
<div class="text-[10.5px] font-mono uppercase tracking-[0.16em] text-[#0284c7]">Starter · Grow · Business</div>
|
||||
</div>
|
||||
<div class="text-[12px] font-mono text-[#0284c7]/80 mb-4">vetted paid mailboxes only</div>
|
||||
<ul class="space-y-2 text-[13px] text-foreground/85">
|
||||
<li class="flex items-start gap-2"><Icon name="check" size={12} class="text-[#0284c7] mt-1 shrink-0"/><span>Stricter quarantine bands than free pool.</span></li>
|
||||
<li class="flex items-start gap-2"><Icon name="check" size={12} class="text-[#0284c7] mt-1 shrink-0"/><span>Quarantined senders move to recovery, not back to premium.</span></li>
|
||||
<li class="flex items-start gap-2"><Icon name="check" size={12} class="text-[#0284c7] mt-1 shrink-0"/><span>Recipients only drawn from other healthy premium mailboxes.</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recovery pool -->
|
||||
<div class="relative rounded-[14px] bg-white ring-1 ring-[color:var(--border)] overflow-hidden">
|
||||
<div class="h-1.5 bg-amber-500"></div>
|
||||
<div class="p-6">
|
||||
<div class="flex items-baseline justify-between mb-1">
|
||||
<div class="text-[18px] font-semibold tracking-[-0.02em] text-heading">Recovery pool</div>
|
||||
<div class="text-[10.5px] font-mono uppercase tracking-[0.16em] text-muted-foreground">Automatic</div>
|
||||
</div>
|
||||
<div class="text-[12px] font-mono text-muted-foreground mb-4">quarantined mailboxes serving cooldown</div>
|
||||
<ul class="space-y-2 text-[13px] text-foreground/75">
|
||||
<li class="flex items-start gap-2"><Icon name="check" size={12} class="text-amber-600 mt-1 shrink-0"/><span>7-day cooldown after quarantine trigger.</span></li>
|
||||
<li class="flex items-start gap-2"><Icon name="check" size={12} class="text-amber-600 mt-1 shrink-0"/><span>Must pass 20-delivery probation to re-enter premium.</span></li>
|
||||
<li class="flex items-start gap-2"><Icon name="check" size={12} class="text-amber-600 mt-1 shrink-0"/><span>Completely isolated from healthy premium senders.</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dedicated -->
|
||||
<div class="relative rounded-[14px] bg-white ring-1 ring-[color:var(--border)] overflow-hidden">
|
||||
<div class="h-1.5 bg-foreground"></div>
|
||||
<div class="p-6">
|
||||
<div class="flex items-baseline justify-between mb-1">
|
||||
<div class="text-[18px] font-semibold tracking-[-0.02em] text-heading">Dedicated</div>
|
||||
<div class="text-[10.5px] font-mono uppercase tracking-[0.16em] text-muted-foreground">Enterprise</div>
|
||||
</div>
|
||||
<div class="text-[12px] font-mono text-muted-foreground mb-4">isolated worker + IP per org</div>
|
||||
<ul class="space-y-2 text-[13px] text-foreground/75">
|
||||
<li class="flex items-start gap-2"><Icon name="check" size={12} class="text-foreground mt-1 shrink-0"/><span>One worker process and IP allocated to one organisation.</span></li>
|
||||
<li class="flex items-start gap-2"><Icon name="check" size={12} class="text-foreground mt-1 shrink-0"/><span>Still participates in the premium pool for warmup recipients.</span></li>
|
||||
<li class="flex items-start gap-2"><Icon name="check" size={12} class="text-foreground mt-1 shrink-0"/><span>Infrastructure isolation, not a reputation shortcut.</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- HEALTH: what slows a mailbox down, and what takes it out. -->
|
||||
<Rule />
|
||||
<section class="container-page py-20 md:py-28">
|
||||
<div class="grid gap-6 lg:grid-cols-[1.1fr_1fr] lg:items-end lg:gap-16">
|
||||
<SectionIntro lead="Health." title="Landing in spam slows a mailbox down. Hurting other people takes it out." />
|
||||
<p class="max-w-xl text-[16px] md:text-[18px] leading-[1.6] text-slate-500 lg:pb-1">
|
||||
Placement is judged at Gmail, Outlook and Yahoo once a mailbox has 20 verified deliveries in a week. Complaint and bounce lines sit below the ones Google and Amazon SES enforce, because a shared pool is only as good as its worst member.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-12 md:mt-16 grid gap-x-3 gap-y-12 lg:grid-cols-2">
|
||||
<article>
|
||||
<PlacementWatchScene />
|
||||
<h3 class={sceneTitle}>Spam placement only ever slows a mailbox down</h3>
|
||||
<p class={sceneBody}>At 10% spam a mailbox goes on watch and sends a little slower. At 20% warmup and cold sending both drop to half volume, and they return to normal on their own once placement recovers.</p>
|
||||
</article>
|
||||
<article>
|
||||
<PoolStandingScene />
|
||||
<h3 class={sceneTitle}>Complaints and bounces take it out of the pool</h3>
|
||||
<p class={sceneBody}>Complaints at 0.10% or bounces at 5% take a mailbox out for 7 days and pause its cold sending; 0.30% or 10% take it out for 30. The standing follows the address, so reconnecting it does not reset anything.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================
|
||||
SELF-HOST NOTICE · the shared pool is a cloud feature
|
||||
============================================================ -->
|
||||
<section class="border-b border-[color:var(--border)] py-14 md:py-16">
|
||||
<div class="container-page">
|
||||
<div class="max-w-2xl mb-8">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Running this yourself</div>
|
||||
<h2 class="text-[26px] md:text-[36px] font-semibold tracking-[-0.025em] leading-[1.08] text-heading">
|
||||
The pool needs scale. That is what the cloud provides.
|
||||
</h2>
|
||||
<p class="mt-4 text-[15px] text-foreground/70 leading-relaxed">
|
||||
Warmbly is open source, so you can self-host all of it. The one part that does not travel is the shared warmup pool, because a pool only works once enough vetted mailboxes are in it.
|
||||
<!-- POOLS: who a mailbox can write to, and why. -->
|
||||
<Rule />
|
||||
<section class="container-page py-20 md:py-28">
|
||||
<div class="grid items-center gap-10 lg:grid-cols-[1.1fr_0.9fr] lg:gap-16">
|
||||
<div class="lg:order-2">
|
||||
<SectionIntro lead="Pools." title="Free and premium mailboxes warm apart." text="Paid plans warm in the premium pool; free workspaces and linked self-hosted instances in the free one. A paying mailbox may also write to free mailboxes from other workspaces once they have been healthy members for three days. A free mailbox only writes back to a paying inbox that wrote to it in the last 14 days." />
|
||||
<p class="mt-4 max-w-xl text-[16px] md:text-[18px] leading-[1.6] text-slate-500">
|
||||
Every draw favours the inbox that is owed the most mail, up to four times as often, and no inbox receives more than about twice what it sends in a day.
|
||||
</p>
|
||||
<p class="mt-4 max-w-xl text-[14px] leading-[1.6] text-slate-400">On the Business plan your mailboxes also sign in to their providers from an address no other customer uses. They still warm in the premium pool and are judged mailbox by mailbox.</p>
|
||||
</div>
|
||||
<WarmupPoolNotice class="max-w-3xl" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================
|
||||
DEFAULTS · clean 2-column spec sheet with big mono values
|
||||
============================================================ -->
|
||||
<section class="py-20 md:py-28">
|
||||
<div class="container-page">
|
||||
<div class="max-w-2xl mb-12">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Defaults</div>
|
||||
<h2 class="text-[32px] md:text-[44px] font-semibold tracking-[-0.03em] leading-[1.05] text-heading">
|
||||
The numbers we ship with.
|
||||
</h2>
|
||||
<p class="mt-4 text-[15.5px] text-foreground/70 leading-relaxed">
|
||||
Tuned for the median sender. Override per mailbox if you have a real reason. The right column is the reasoning we will defend in an incident review.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 2-column grid of spec rows with big mono values -->
|
||||
<div class="grid md:grid-cols-2 gap-x-12 gap-y-px">
|
||||
{defaults.map((d, i) => (
|
||||
<div class="group grid grid-cols-[1fr_auto] gap-6 py-6 border-b border-[color:var(--border)] items-baseline transition-colors hover:bg-[color:var(--surface-1)]/40 -mx-3 px-3 rounded-[6px]">
|
||||
<div>
|
||||
<div class="text-[10.5px] uppercase tracking-[0.22em] font-mono text-muted-foreground">
|
||||
{String(i + 1).padStart(2, '0')} · {d.k}
|
||||
</div>
|
||||
<p class="mt-2 text-[13.5px] text-foreground/65 leading-relaxed max-w-md">{d.why}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-[22px] md:text-[26px] font-semibold tracking-[-0.02em] text-heading font-mono whitespace-nowrap">{d.v}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="mt-8 text-[11.5px] font-mono text-muted-foreground">
|
||||
Source: <span class="text-foreground/75">internal/scheduler/warmup_scheduler.go</span> · <span class="text-foreground/75">internal/repository/pg_warmup.go</span>
|
||||
<div class="lg:order-1">
|
||||
<PoolsScene />
|
||||
</div>
|
||||
</div>
|
||||
<WarmupPoolNotice class="mt-14 max-w-3xl" />
|
||||
</section>
|
||||
|
||||
<!-- ============================================================
|
||||
FAQ — split sticky title (pricing pattern)
|
||||
============================================================ -->
|
||||
<section class="bg-[color:var(--surface-1)]/40 border-t border-[color:var(--border)] py-20 md:py-24">
|
||||
<div class="container-page grid lg:grid-cols-[1fr_1.8fr] gap-12 lg:gap-20 items-start">
|
||||
<div class="lg:sticky lg:top-24" style="align-self: start;">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Warmup FAQ</div>
|
||||
<h2 class="text-[28px] md:text-[36px] font-semibold tracking-[-0.025em] leading-[1.08] text-heading">
|
||||
Four questions we get most.
|
||||
</h2>
|
||||
<p class="mt-4 text-[14.5px] text-foreground/70 leading-relaxed">
|
||||
Want the full mental model? Read the <a class="text-[#0369a1] hover:text-[#075985] font-medium" href="https://docs.warmbly.com/learn/email-warmup/">warmup guide</a>.
|
||||
</p>
|
||||
</div>
|
||||
<div class="divide-y divide-[color:var(--border)]" data-faq>
|
||||
{faq.map(([q, a]) => (
|
||||
<div class="faq-row py-2">
|
||||
<button type="button" class="faq-trigger group w-full flex items-start justify-between gap-4 py-3 text-left" aria-expanded="false">
|
||||
<span class="text-[15.5px] font-medium text-heading group-hover:text-[#0369a1] transition-colors">{q}</span>
|
||||
<span class="faq-icon shrink-0 inline-flex items-center justify-center w-7 h-7 rounded-full bg-white ring-1 ring-[color:var(--border)] text-foreground/60 transition-transform duration-300 ease-out">
|
||||
<Icon name="plus" size={12} />
|
||||
</span>
|
||||
</button>
|
||||
<div class="faq-panel grid grid-rows-[0fr] transition-[grid-template-rows] duration-300 ease-out">
|
||||
<div class="overflow-hidden">
|
||||
<p class="pb-4 pr-12 text-[14px] text-foreground/75 leading-relaxed max-w-2xl">{a}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<script is:inline>
|
||||
(function () {
|
||||
const faq = document.querySelector('[data-faq]');
|
||||
if (!faq) return;
|
||||
faq.querySelectorAll('.faq-trigger').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
const row = btn.closest('.faq-row');
|
||||
const panel = row.querySelector('.faq-panel');
|
||||
const icon = btn.querySelector('.faq-icon');
|
||||
const open = btn.getAttribute('aria-expanded') === 'true';
|
||||
btn.setAttribute('aria-expanded', String(!open));
|
||||
panel.style.gridTemplateRows = open ? '0fr' : '1fr';
|
||||
if (icon) icon.style.transform = open ? 'rotate(0deg)' : 'rotate(45deg)';
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<!-- DEFAULTS: four numbers in a row. -->
|
||||
<Rule />
|
||||
<section class="container-page py-16 md:py-20">
|
||||
<div class="grid grid-cols-2 gap-y-10 md:grid-cols-4">
|
||||
{defaults.map((d) => (
|
||||
<div class="md:border-l md:border-slate-200 md:pl-6 md:first:border-l-0 md:first:pl-0">
|
||||
<div class="text-[44px] md:text-[56px] font-medium tracking-[-0.05em] leading-none text-slate-900">{d.v}</div>
|
||||
<div class="mt-2 text-[14.5px] text-slate-500">{d.k}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p class="mt-10 text-[14px] text-slate-400">Every default can be changed per mailbox.</p>
|
||||
</section>
|
||||
|
||||
<Rule />
|
||||
<Faq items={faq} title="Warmup, answered.">
|
||||
<p class="mt-4 text-[16px] leading-[1.6] text-slate-500">
|
||||
For the whole picture, read the <a class="font-medium text-slate-900 underline decoration-slate-300 underline-offset-4 hover:decoration-slate-900" href="https://docs.warmbly.com/learn/email-warmup/">warmup guide</a>.
|
||||
</p>
|
||||
</Faq>
|
||||
|
||||
<PillarPosts
|
||||
title="How warmup actually behaves"
|
||||
title="More on warmup"
|
||||
items={[
|
||||
{ kicker: 'Warmup', href: '/blog/how-email-warmup-actually-works/', title: 'How email warmup actually works, ramp curve and all' },
|
||||
{ kicker: 'Policy', href: '/blog/quarantine-early/', title: 'Quarantine early: how we protect shared warmup pools' },
|
||||
@@ -636,10 +196,8 @@ for (let wk = 0; wk < 5; wk++) {
|
||||
]}
|
||||
/>
|
||||
|
||||
<CTA
|
||||
title="Connect a mailbox. Start warming today."
|
||||
description="OAuth in 30 seconds. First warmup send in under a minute."
|
||||
primaryLabel="Start free"
|
||||
primaryHref="https://app.warmbly.com/auth/register"
|
||||
<CTA photo="sheer-blue"
|
||||
title="Connect a mailbox and start warming today."
|
||||
description="Sign in with Google or Microsoft, or add any SMTP account, then switch warmup on from the mailbox's Warmup tab."
|
||||
/>
|
||||
</Layout>
|
||||
|
||||
Reference in New Issue
Block a user