feat: redesign analytics page

Refresh the analytics marketing page around actionable deliverability metrics, realtime dashboard behavior, export surfaces, and a share-card mock that mirrors the product UI.
This commit is contained in:
Matthew Meszaros
2026-06-09 05:13:23 +00:00
parent c1e1748e93
commit a5c8a42a06
2 changed files with 481 additions and 173 deletions
+157
View File
@@ -0,0 +1,157 @@
---
// Pixel-perfect static recreation of the in-app "Share image" card
// (web/src/components/app/analytics/StatsShareCard.tsx, 1:1 preset).
//
// Every dimension is expressed in container-query width units (cqw), mapped
// 1:1 from the real 1080px design (px / 1080 * 100), so the card stays exact at
// any rendered width and on any device. No JS, no fixed pixels.
interface Props {
title?: string;
subtitle?: string;
date?: string;
metrics?: { label: string; value: string; sub?: string }[];
}
const {
title = 'Workspace performance',
subtitle = 'Last 7 days',
date = 'Jun 9, 2026',
metrics = [
{ label: 'Sent', value: '12,847', sub: 'emails' },
{ label: 'Open rate', value: '34.2%' },
{ label: 'Reply rate', value: '6.1%' },
{ label: 'Bounce rate', value: '1.8%' },
],
} = Astro.props;
// Area-chart geometry — identical math to ShareAreaChart in the app.
const vals = [22, 30, 24, 38, 33, 45, 40, 29, 48, 55, 50, 58, 64, 60, 69, 76, 72, 66, 79, 86, 81, 74, 88, 94, 90, 83, 96, 100];
const W = 1000, H = 320, padX = 4, padTop = 14;
const baseY = H - 6;
const max = Math.max(1, ...vals);
const w = W - padX * 2, h = baseY - padTop;
const step = w / (vals.length - 1);
const pts = vals.map((v, i) => [padX + i * step, baseY - (v / max) * h] as [number, number]);
const line = pts.map(([x, y], i) => `${i === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`).join(' ');
const lastX = padX + (vals.length - 1) * step;
const area = `${line} L ${lastX.toFixed(1)} ${baseY} L ${padX} ${baseY} Z`;
const cols = metrics.length || 1;
---
<div class="sc-card">
<div class="sc-backdrop">
<div class="sc-breathe"></div>
<div class="sc-haze"></div>
</div>
<div class="sc-inner">
<!-- logo + date on the sky -->
<div class="sc-top">
<div class="sc-brand">
<svg class="sc-logo" viewBox="0 0 746 764" fill="none" aria-hidden="true">
<path d="M222.805 644.772L186.274 108.881L704.5 451.158L484.5 451.158L245.5 196.158L444 463.5L222.805 644.772Z" fill="currentColor" />
</svg>
<span class="sc-word">Warmbly</span>
</div>
<span class="sc-date">{date}</span>
</div>
<!-- the single white panel -->
<div class="sc-panel">
<div class="sc-panelpad">
<div class="sc-subtitle">{subtitle}</div>
<h3 class="sc-title">{title}</h3>
<div class="sc-metrics" style={`grid-template-columns: repeat(${cols}, minmax(0,1fr));`}>
{metrics.map((m) => (
<div class="sc-metric">
<div class="sc-mlabel">{m.label}</div>
<div class="sc-mvalue">{m.value}</div>
{m.sub && <div class="sc-msub">{m.sub}</div>}
</div>
))}
</div>
<div class="sc-hr"></div>
<div class="sc-chartlabel">Sends over time</div>
<div class="sc-chartwrap">
<div class="sc-chartsvg">
<svg viewBox="0 0 1000 320" preserveAspectRatio="none" width="100%" height="100%">
<defs>
<linearGradient id="scArea" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#0ea5e9" stop-opacity="0.28" />
<stop offset="100%" stop-color="#0ea5e9" stop-opacity="0" />
</linearGradient>
</defs>
<path d={area} fill="url(#scArea)" />
<path d={line} fill="none" stroke="#0284c7" stroke-width="3" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke" />
<line x1={padX} y1={baseY} x2={W - padX} y2={baseY} stroke="#cbd5e1" stroke-width="2" vector-effect="non-scaling-stroke" />
</svg>
</div>
<div class="sc-chartdates">
<span>May 13</span>
<span>Jun 9</span>
</div>
</div>
</div>
</div>
<!-- footer on the sky -->
<div class="sc-foot">
<span class="sc-foot-l">warmbly.com</span>
<span class="sc-foot-r">Cold email, warmed up.</span>
</div>
</div>
</div>
<style>
.sc-card {
container-type: inline-size;
position: relative;
width: 100%;
aspect-ratio: 1 / 1;
overflow: hidden;
border-radius: 0;
font-family: var(--font-sans);
background: radial-gradient(ellipse 125% 105% at 50% -12%, #9fe0fb 0%, #74cef7 30%, #4ec1f6 58%, #2fb6f2 82%, #18abed 100%);
}
.sc-backdrop { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }
.sc-breathe { position: absolute; inset: 0; opacity: 0.6;
background: radial-gradient(ellipse 125% 105% at 50% -12%, rgba(224,242,254,0.85) 0%, rgba(125,211,252,0.38) 24%, rgba(56,189,248,0.14) 46%, transparent 64%); }
.sc-haze { position: absolute; left: 0; right: 0; bottom: 0; height: 55%;
background: linear-gradient(to top, rgba(186,230,253,0.40) 0%, rgba(125,211,252,0.16) 42%, transparent 100%); }
.sc-inner { position: relative; z-index: 10; display: flex; flex-direction: column; height: 100%; padding: 4.444cqw; }
.sc-top { display: flex; align-items: center; justify-content: space-between; }
.sc-brand { display: inline-flex; align-items: center; gap: 1.296cqw; }
.sc-logo { width: 5cqw; height: 5cqw; color: #fff; display: block; }
.sc-word { color: #fff; font-family: var(--font-display); font-weight: 800; letter-spacing: -0.02em; font-size: 3.333cqw; line-height: 1; }
.sc-date { font-family: var(--font-mono); font-size: 1.389cqw; color: rgba(255,255,255,0.85); font-variant-numeric: tabular-nums; }
.sc-panel { margin-top: 2.222cqw; flex: 1; min-height: 0; display: flex; flex-direction: column;
background: #fff; border-radius: 2.407cqw; border: 1px solid rgba(255,255,255,0.85); }
.sc-panelpad { display: flex; flex-direction: column; height: 100%; padding: 3.704cqw; }
.sc-subtitle { font-size: 1.296cqw; font-weight: 600; text-transform: uppercase; letter-spacing: 0.16em; color: #0284c7; }
.sc-title { margin-top: 0.741cqw; font-size: 3.704cqw; font-weight: 600; line-height: 1.15; letter-spacing: -0.02em; color: #0f172a; }
.sc-metrics { margin-top: 2.963cqw; display: grid; }
.sc-metric { padding: 0 2.593cqw; border-left: 1px solid #e2e8f0; }
.sc-metric:first-child { padding-left: 0; border-left: 0; }
.sc-metric:last-child { padding-right: 0; }
.sc-mlabel { font-size: 1.296cqw; font-weight: 500; text-transform: uppercase; letter-spacing: 0.14em; color: #94a3b8; }
.sc-mvalue { margin-top: 0.926cqw; font-family: var(--font-mono); font-size: 4.259cqw; line-height: 1; color: #0f172a; font-variant-numeric: tabular-nums; }
.sc-msub { margin-top: 0.741cqw; font-size: 1.296cqw; color: #94a3b8; }
.sc-hr { margin-top: 2.963cqw; height: 1px; background: rgba(226,232,240,0.7); }
.sc-chartlabel { margin-top: 2.222cqw; font-size: 1.296cqw; font-weight: 500; text-transform: uppercase; letter-spacing: 0.14em; color: #94a3b8; }
.sc-chartwrap { margin-top: 1.481cqw; flex: 1; min-height: 0; display: flex; flex-direction: column; }
.sc-chartsvg { position: relative; flex: 1; min-height: 0; }
.sc-chartsvg svg { display: block; width: 100%; height: 100%; }
.sc-chartdates { margin-top: 1.111cqw; display: flex; justify-content: space-between; font-family: var(--font-mono); font-size: 1.111cqw; color: #94a3b8; font-variant-numeric: tabular-nums; }
.sc-foot { margin-top: 1.852cqw; display: flex; align-items: center; justify-content: space-between; font-size: 1.481cqw; }
.sc-foot-l { color: #fff; font-weight: 600; }
.sc-foot-r { color: rgba(255,255,255,0.8); }
</style>
+324 -173
View File
@@ -1,66 +1,161 @@
---
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 ShareCardMock from '../components/ShareCardMock.astro';
// Source: CLAUDE.md analytics + admin sections, internal/repository/*
// Source anchors for everything claimed on this page:
// web/src/app/app/analytics/page.tsx (the real dashboard this mirrors)
// realtime/lib/realtime/* (the websocket fanout)
// tracking/src/handlers.rs (open/click capture + dedupe)
// internal/app/consumer/* (reply classify, deliverability)
// internal/app/advanced/service.go (suppression)
// internal/repository/pg_* (the aggregates the dashboard reads)
// What we lead with vs what we treat as noise
// =========================================================================
// What we lead with vs what we treat as noise.
// =========================================================================
const signal = [
{ k: 'Inbox placement rate', why: 'Probed via warmup pool partners. The only number that tells you mail is actually being read.' },
{ k: 'Complaint rate', why: 'ARF feedback loops + Gmail Postmaster Tools, aggregated per mailbox. Hard ceiling at 0.10%.' },
{ k: 'Bounce rate', why: 'Hard vs soft split per mailbox. SES enforcement begins at 5%, suspension at 10%.' },
{ k: 'Reply rate', why: 'Per sequence, per step, per variant. The metric a cold-mail program is actually optimising for.' },
{ k: 'Positive-reply rate', why: 'Replies classified as positive (vs OOO, negative, unsubscribe). Strips noise from reply rate.' },
{ k: 'Suppression growth', why: 'Share of contacted recipients added to suppression per period. Spikes are a leading indicator.' },
{ k: 'Inbox placement rate', why: 'Probed through warmup pool partners. The only number that says mail is actually being read.' },
{ k: 'Reply rate', why: 'Per sequence, per step, per variant. The metric a cold program is genuinely optimising for.' },
{ k: 'Positive-reply rate', why: 'Replies classified positive, stripped of OOO, negative, and opt-out. The real one.' },
{ k: 'Complaint rate', why: 'FBL and ARF loops plus Postmaster, per mailbox. Hard ceiling at 0.10%.' },
{ k: 'Bounce rate', why: 'Hard and soft, split per mailbox. SES review at 5%, pause at 10%.' },
{ k: 'Suppression growth', why: 'Share of contacted recipients suppressed per period. A spike leads the bad news.' },
];
const noise = [
{ k: 'Open rate', why: 'Apple Mail Privacy proxies opens for ~25-45% of B2B inboxes. Gmail caches images. The number is noise dominated.' },
{ k: 'Cold first-touch CTR', why: 'Most well-written cold mail has no links. Optimising for clicks incentivises link-heavy content, which is itself a spam-folder signal.' },
{ k: '"Deliverability score"', why: 'From vendors who will not publish the formula. Unauditable and gameable.' },
{ k: 'Open rate', why: 'Apple Mail Privacy prefetches pixels for a big share of B2B inboxes, so the number reads near 100% within minutes. We show it, we never decide on it.' },
{ k: 'First-touch click rate', why: 'Good cold mail has no links. Optimising for clicks pushes you toward link-heavy mail, which is itself a spam signal.' },
{ k: '"Deliverability score"', why: 'A single vendor number with an unpublished formula. Unauditable and easy to game.' },
];
// Real export surfaces
// =========================================================================
// Drill path. Every workspace KPI resolves down to one recipient event.
// =========================================================================
const drill = [
{ k: 'Workspace', v: 'one number', d: 'The KPI strip: sent, reply rate, bounce rate, placement.' },
{ k: 'Campaign', v: 'top performers', d: 'Which campaigns carry the result, ranked by reply rate.' },
{ k: 'Sequence', v: 'per step', d: 'Step one against the follow-ups. Where replies actually come from.' },
{ k: 'Variant', v: 'A / B', d: 'Subject and body variants, scored on positive replies.' },
{ k: 'Mailbox', v: 'per sender', d: 'The same metrics per mailbox, pool, and worker IP.' },
{ k: 'Recipient', v: 'one event', d: 'The exact open, reply, or bounce behind the number.' },
];
// =========================================================================
// What updates live, over the websocket fanout.
// =========================================================================
const live = [
{ k: 'Counts and KPIs', d: 'Sent, replies, and bounce rate tick up the moment events arrive.' },
{ k: 'Lists', d: 'Campaign and mailbox tables reorder themselves without a refresh.' },
{ k: 'Detail panes', d: 'An open drawer keeps updating while you are reading it.' },
{ k: 'Activity feed', d: 'New opens, clicks, and replies appear at the top instantly.' },
{ k: 'Account health', d: 'A mailbox slipping into watch shows the second it crosses.' },
];
// =========================================================================
// Exactly what each headline number is, and where it comes from.
// =========================================================================
const definitions = [
{ k: 'Reply rate', f: 'replies ÷ delivered', src: 'consumer.reply' },
{ k: 'Positive reply', f: 'positive ÷ delivered', src: 'consumer.reply.classifier' },
{ k: 'Inbox placement', f: 'inbox ÷ (inbox + spam + promo)', src: 'warmup pool probe' },
{ k: 'Complaint rate', f: 'complaints ÷ delivered', src: 'consumer.deliverability' },
{ k: 'Bounce rate', f: '(hard + soft) ÷ sent', src: 'consumer.deliverability' },
{ k: 'Open rate', f: 'opens ÷ delivered', src: 'tracking.handlers' },
];
// =========================================================================
// Real export surfaces.
// =========================================================================
const exports = [
{ k: 'On-demand download', v: 'CSV · XLSX · JSON', why: 'Pick a report, choose fields, download. No row caps.' },
{ k: 'Scheduled email', v: 'Daily · weekly', why: 'Workspace-level summary or a specific saved view to your inbox.' },
{ k: 'Webhooks', v: 'HMAC-signed', why: 'Per-event push for replies, bounces, complaints, suppression and band transitions.' },
{ k: 'REST API', v: 'GET / events', why: 'Pull events into your warehouse. Idempotency keys + rate limits surfaced in headers.' },
{ k: 'S3 push', v: 'Hourly · gzipped', why: 'Newline-delimited JSON to your bucket. Schemas versioned in the event payload.' },
{ k: 'On-demand download', v: 'CSV · XLSX · JSON', why: 'Pick a report, choose fields, download. No row caps.' },
{ k: 'Scheduled email', v: 'Daily · weekly', why: 'A workspace summary or a saved view, dropped in your inbox.' },
{ k: 'Webhooks', v: 'HMAC-signed', why: 'Per-event push for replies, bounces, complaints, suppression, and band changes.' },
{ k: 'REST API', v: 'GET / events', why: 'Pull events into your warehouse. Idempotency keys and rate limits in the headers.' },
{ k: 'S3 push', v: 'Hourly · gzipped', why: 'Newline-delimited JSON to your bucket. Schema versioned in the payload.' },
];
const faq = [
['Why no open rate as a headline number?', 'Apple Mail Privacy Protection prefetches all tracking pixels through Apple proxies for a large share of B2B recipients. The reported open rate is functionally 100% for those mailboxes within minutes of delivery. We surface opens but never use them for deliverability decisions or ranking.'],
['Where does placement data come from?', 'Warmbly\'s warmup pool double-serves as a placement probe. When a partner mailbox receives a warmup message, it reports the folder (Inbox / Promotions / Spam) along with a verification token. That feeds the placement signal for the sending mailbox.'],
['How long is data retained?', 'Per-event records (sends, replies, opens, bounces) for 18 months by default. Aggregates retained indefinitely. Custom retention available on Enterprise.'],
['Can I export raw events to my warehouse?', 'Yes. Webhooks for real-time push, S3 hourly drops for batched ingestion, or pull through the events API with cursor pagination. Same event schema across all three.'],
['Why is open rate not a headline number?',
'Apple Mail Privacy Protection prefetches every tracking pixel through Apple proxies for a large share of B2B recipients, so the reported open rate is effectively 100% for those mailboxes within minutes of delivery. We still surface opens, but we never use them for deliverability decisions or ranking.'],
['Where does placement data come from?',
'The warmup pool double-serves as a placement probe. When a partner mailbox receives a warmup message, it reports the folder it landed in (Inbox, Promotions, or Spam) along with a signed verification token. That feeds the placement signal for the sending mailbox.'],
['Is everything really realtime?',
'Yes. Opens, clicks, replies, and bounces stream into the dashboard over a websocket as the consumer processes them, so counts, lists, and the activity feed update without a refresh. Exports and rollups run on top of the same events.'],
['How long is data retained?',
'Per-event records (sends, replies, opens, bounces) for 18 months by default, aggregates indefinitely. Custom retention is available on Enterprise.'],
['Can I get the raw events into my warehouse?',
'Yes. Webhooks for realtime push, hourly gzipped S3 drops for batch ingestion, or pull through the events API with cursor pagination. The same event schema is used across all three.'],
];
---
<Layout
title="Deliverability Analytics & Placement | Warmbly"
description="Deliverability-aware analytics: per-mailbox placement, complaint and bounce rates, warmup health, sequence conversion. No vanity opens."
title="Deliverability analytics, down to the recipient | Warmbly"
description="Signal-first analytics: placement, reply and positive-reply rate, complaints, bounces, suppression. Every number drills to the recipient behind it, and the whole dashboard updates live."
>
<!-- ============================================================
HERO · same HeroAtmosphere pattern
HERO
============================================================ -->
<section class="relative isolate overflow-hidden">
<HeroAtmosphere />
<div class="container-page relative pt-16 md:pt-24 pb-44 md:pb-56 text-center">
<!-- a few tasteful floating signals: ones we trust, one we don't -->
<div class="hidden xl:block absolute inset-0 z-0 pointer-events-none select-none" aria-hidden="true">
<!-- positive reply (signal) -->
<div class="hero-float absolute top-[10%] right-[calc(50%_-_462px)] z-30" style="--drift:-10px;animation-duration:7.2s;animation-delay:-1.4s">
<div class="rotate-[2deg] px-3.5 py-2.5 rounded-[12px] bg-white ring-1 ring-slate-900/5 shadow-[0_2px_4px_-2px_rgba(2,32,71,0.25),0_16px_34px_-12px_rgba(2,32,71,0.5)]">
<div class="flex items-center gap-2">
<span class="inline-flex size-5 items-center justify-center rounded-md bg-emerald-50 ring-1 ring-emerald-200 text-emerald-600 shrink-0">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>
</span>
<span class="text-[12px] font-semibold text-slate-800">positive reply</span>
<span class="font-mono text-[12px] font-semibold text-emerald-600 tabular-nums">+1</span>
</div>
</div>
</div>
<!-- placement (signal) -->
<div class="hero-float absolute top-[82%] right-[calc(50%_-_444px)] z-20" style="--drift:-7px;animation-duration:8.1s;animation-delay:-0.7s">
<div class="rotate-[-2deg] h-7 px-3 rounded-full bg-white/95 ring-1 ring-slate-900/5 shadow-[0_8px_20px_-10px_rgba(2,32,71,0.4)] inline-flex items-center gap-1.5">
<span class="w-1.5 h-1.5 rounded-full bg-sky-500"></span>
<span class="text-[11px] font-medium text-slate-600">inbox placement</span>
<span class="font-mono text-[11px] font-semibold text-slate-800 tabular-nums">94%</span>
</div>
</div>
<!-- open (noise) -->
<div class="hero-float absolute top-[12%] left-[calc(50%_-_452px)] z-20" style="--drift:-9px;animation-duration:6.8s;animation-delay:-2.2s">
<div class="rotate-[-3deg] px-3 py-2 rounded-[12px] bg-white/90 ring-1 ring-slate-900/5 shadow-[0_2px_3px_-2px_rgba(2,32,71,0.2),0_12px_28px_-14px_rgba(2,32,71,0.4)]">
<div class="flex items-center gap-2">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="2" class="shrink-0" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>
<span class="text-[12px] font-medium text-slate-400 line-through decoration-slate-300">open rate</span>
<span class="rounded bg-slate-100 px-1 py-px text-[8.5px] font-semibold uppercase tracking-[0.1em] text-slate-400">noise</span>
</div>
</div>
</div>
<!-- bounce -> suppressed (signal) -->
<div class="hero-float absolute top-[85%] left-[calc(50%_-_446px)] z-10" style="--drift:-8px;animation-duration:7.6s;animation-delay:-3s">
<div class="rotate-[2.5deg] h-7 px-3 rounded-full bg-white/95 ring-1 ring-slate-900/5 shadow-[0_8px_20px_-10px_rgba(2,32,71,0.4)] inline-flex items-center gap-1.5">
<span class="w-1.5 h-1.5 rounded-full bg-rose-500"></span>
<span class="text-[11px] font-medium text-slate-600">bounced, suppressed</span>
</div>
</div>
</div>
<div class="container-page relative z-10 pt-16 md:pt-24 pb-32 md:pb-44 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;">
Analytics
</span>
Numbers we trust. Not vanity opens.
Signal, not vanity opens.
</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">
Numbers we trust.<br/>Not vanity opens.
Numbers you can<br/>actually act on.
</h1>
<p class="mt-6 text-[17px] md:text-[19px] text-white/80 max-w-2xl mx-auto leading-relaxed">
Placement, complaints, bounces, reply rate, positive-reply rate, suppression growth. Per mailbox, per pool, per worker, per sequence. Drill from a workspace KPI to the exact recipient behind it.
Placement, reply and positive-reply rate, complaints, bounces, suppression. Every number drills from a workspace KPI to the exact recipient behind it, and the whole dashboard updates live.
</p>
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
@@ -70,153 +165,31 @@ const faq = [
<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>
</svg>
</a>
<a href="/learn/deliverability/" 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">
Read the handbook
<a href="#signal" 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">
What we measure
</a>
</div>
</div>
</section>
<!-- ============================================================
DASHBOARD MOCK · floats below the hero, mirrors the real
product page (web/src/app/app/analytics/page.tsx).
============================================================ -->
<section class="relative -mt-36 md:-mt-44 pb-16 md:pb-24 z-10">
<div class="container-page">
<div class="rounded-[14px] bg-white ring-1 ring-slate-200 overflow-hidden shadow-[0_40px_90px_-30px_rgba(2,32,71,0.35),0_12px_30px_-12px_rgba(15,23,42,0.12)] flex flex-col">
<!-- PageTopbar -->
<div class="h-14 px-5 border-b border-slate-200 flex items-center gap-3 shrink-0 bg-white">
<div>
<div class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Analytics</div>
<div class="mt-0.5 text-[13px] text-slate-900 font-medium">Deliverability across the workspace</div>
</div>
<div class="ml-auto inline-flex items-center gap-0.5 rounded-md border border-slate-200 bg-white p-0.5">
{['7d', '30d', '90d'].map((o) => (
<button type="button" class={`h-6 px-2 rounded text-[11px] font-medium tabular-nums transition-colors ${o === '7d' ? 'bg-slate-900 text-white' : 'text-slate-500 hover:text-slate-900'}`}>
{o}
</button>
))}
</div>
</div>
<!-- StatStrip · 4 cols -->
<div class="grid grid-cols-2 lg:grid-cols-4 border-b border-slate-200">
{[
{ label: 'Total sent', value: '12,847', sub: 'last 7 days' },
{ label: 'Open rate', value: '34.2%', sub: 'after delivery' },
{ label: 'Reply rate', value: '6.1%', sub: 'incl. positive' },
{ label: 'Bounce rate', value: '1.8%', sub: 'hard + soft' },
].map((s, i) => (
<div class={`p-5 ${i < 3 ? 'lg:border-r border-slate-200' : ''} ${i < 2 ? 'border-b lg:border-b-0 border-slate-200' : ''}`}>
<div class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">{s.label}</div>
<div class="mt-1.5 text-[24px] font-semibold tracking-[-0.02em] text-slate-900 leading-none font-mono tabular-nums">{s.value}</div>
<div class="mt-1.5 text-[11px] text-slate-400 font-mono">{s.sub}</div>
</div>
))}
</div>
<!-- Body: chart + breakdown -->
<div class="grid lg:grid-cols-[1fr_320px] min-h-0">
<section class="flex flex-col min-h-0 lg:border-r border-slate-200">
<div class="h-9 px-4 border-b border-slate-200 flex items-center gap-2 shrink-0">
<span class="text-[11px] font-medium text-slate-700">Email performance</span>
</div>
<div class="flex-1 flex flex-col px-5 py-4">
<div class="h-52 flex items-end gap-0.5">
{Array.from({ length: 28 }).map((_, i) => {
const v = 22 + Math.sin(i * 0.5) * 18 + ((i * 13) % 17);
return (
<div class="flex-1 flex items-end">
<div class="w-full rounded-sm bg-sky-100" style={`height: ${v}%`}></div>
</div>
);
})}
</div>
<div class="flex justify-between mt-2 font-mono text-[10px] text-slate-400">
<span>7d ago</span>
<span>today</span>
</div>
</div>
</section>
<aside class="flex flex-col min-h-0 bg-slate-50/40">
<div class="h-9 px-4 border-b border-slate-200 flex items-center gap-2 shrink-0">
<span class="text-[11px] font-medium text-slate-700">Breakdown</span>
</div>
<div class="divide-y divide-slate-200/60">
{[
{ label: 'Delivered', dot: 'bg-emerald-500', v: '12,847' },
{ label: 'Opened', dot: 'bg-sky-500', v: '4,394' },
{ label: 'Replied', dot: 'bg-violet-500', v: '783' },
{ label: 'Bounced', dot: 'bg-amber-500', v: '231' },
{ label: 'Spam', dot: 'bg-red-500', v: '5' },
].map((q) => (
<div class="h-9 px-4 flex items-center gap-2">
<span class={`w-1.5 h-1.5 rounded-full ${q.dot}`}></span>
<span class="text-[12px] text-slate-700">{q.label}</span>
<span class="ml-auto font-mono text-[11px] text-slate-500 tabular-nums">{q.v}</span>
</div>
))}
</div>
</aside>
</div>
<!-- Warmup section bar + mailbox strip -->
<div class="h-9 px-4 border-t border-b border-slate-200 flex items-center gap-2 shrink-0">
<span class="text-[11px] font-medium text-slate-700">Warmup</span>
<a href="#" class="ml-auto inline-flex items-center gap-1 text-[11px] text-slate-500 hover:text-slate-900 transition-colors">
All accounts
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 7h10v10"/><path d="M7 17 17 7"/></svg>
</a>
</div>
<div class="px-5 py-5 grid grid-cols-2 lg:grid-cols-4 gap-3">
{[
{ mb: 'ben@acme.com', day: 22, today: 32, cap: 40, state: 'healthy' },
{ mb: 'sara@acme.com', day: 14, today: 24, cap: 40, state: 'healthy' },
{ mb: 'mark@acme.com', day: 31, today: 40, cap: 40, state: 'watch' },
{ mb: 'kai@acme.com', day: 9, today: 19, cap: 40, state: 'healthy' },
].map((m) => {
const pct = Math.round((m.today / m.cap) * 100);
const chip = m.state === 'watch' ? 'bg-amber-50 text-amber-700' : 'bg-emerald-50 text-emerald-700';
const dot = m.state === 'watch' ? 'bg-amber-500' : 'bg-emerald-500';
const bar = m.state === 'watch' ? 'bg-amber-500' : 'bg-sky-500';
return (
<div class="rounded-md ring-1 ring-slate-200 bg-white p-3">
<div class="flex items-baseline justify-between gap-2">
<span class="font-mono text-[11px] text-slate-700 truncate">{m.mb}</span>
<span class={`inline-flex items-center gap-1 h-4 px-1 rounded text-[9.5px] font-medium ${chip}`}>
<span class={`w-1 h-1 rounded-full ${dot}`}></span>
{m.state}
</span>
</div>
<div class="mt-2 flex items-baseline gap-1 font-mono">
<span class="text-[18px] font-semibold text-slate-900 tabular-nums">{m.today}</span>
<span class="text-[10px] text-slate-400">/ {m.cap} today · D{m.day}</span>
</div>
<div class="mt-2 h-1 rounded-full bg-slate-100 overflow-hidden">
<div class={`h-full ${bar}`} style={`width: ${pct}%`}></div>
</div>
</div>
);
})}
</div>
</div>
</div>
</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>
<!-- ============================================================
SIGNAL vs NOISE · what we measure, what we ignore
SIGNAL vs NOISE
============================================================ -->
<section class="border-y border-[color:var(--border)] bg-[color:var(--surface-1)]/40 py-20 md:py-28">
<section id="signal" class="border-y border-[color:var(--border)] bg-[color:var(--surface-2)]/50 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">Signal vs noise</div>
<h2 class="text-[32px] md:text-[44px] font-semibold tracking-[-0.03em] leading-[1.05] text-heading">
We lead with six metrics. Three we ignore.
Six numbers we lead with. Three we ignore.
</h2>
<p class="mt-4 text-[15.5px] text-foreground/70 leading-relaxed">
A metric is a signal only if it is reliable and acts on. Open rate fails both tests today. Click rate on cold first-touch fails the second. Vendor "scores" fail the first.
A metric earns the headline only if it is reliable and you can act on it. Open rate fails the first test today. First-touch click rate fails the second. Vendor "scores" fail both.
</p>
</div>
@@ -232,7 +205,7 @@ const faq = [
</div>
<ul class="divide-y divide-[color:var(--border)]">
{signal.map((s) => (
<li class="px-6 py-4 grid grid-cols-[180px_1fr] gap-4 items-baseline">
<li class="px-6 py-4 grid grid-cols-[170px_1fr] gap-4 items-baseline">
<div class="text-[13.5px] font-semibold text-heading">{s.k}</div>
<div class="text-[12.5px] text-foreground/65 leading-snug">{s.why}</div>
</li>
@@ -262,11 +235,182 @@ const faq = [
</div>
</section>
<!-- ============================================================
SHARE IMAGE · pixel-perfect demo of the in-app feature
============================================================ -->
<section class="bg-white border-b border-[color:var(--border)] py-20 md:py-28">
<div class="container-page grid lg:grid-cols-[1fr_1.4fr] gap-12 lg:gap-16 items-center">
<!-- left: narrative + how it works -->
<div>
<div class="text-[11px] uppercase tracking-[0.18em] font-mono text-[#0284c7] mb-3">Share image</div>
<h2 class="text-[28px] md:text-[40px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading">
Post your numbers without a screenshot.
</h2>
<p class="mt-4 text-[15px] text-foreground/70 leading-relaxed max-w-md">
One tap turns the workspace view into a branded image: your headline metrics and the sends trend on a clean Warmbly sky card. Pick an aspect, preview it exactly as it will export, then copy or download a crisp PNG.
</p>
<div class="mt-7 space-y-4 max-w-md">
{[
{ k: 'Pick an aspect', d: '1:1 for social, 3:2 for slides, 16:9 for a banner.' },
{ k: 'Preview live', d: 'The card renders exactly as it exports, every metric in place.' },
{ k: 'Copy or download', d: 'Straight to the clipboard, or a high-resolution PNG to disk.' },
].map((s, i) => (
<div class="flex items-start gap-3">
<span class="mt-px w-5 shrink-0 font-mono text-[11px] text-[#0284c7] font-semibold tabular-nums">{String(i + 1).padStart(2, '0')}</span>
<div>
<div class="text-[14px] font-semibold text-heading">{s.k}</div>
<p class="mt-0.5 text-[13px] text-foreground/65 leading-relaxed">{s.d}</p>
</div>
</div>
))}
</div>
</div>
<!-- right: the exported card, up close (the static artifact) -->
<div class="w-full">
<div class="mx-auto w-full max-w-[440px]">
<div class="rounded-[14px] overflow-hidden ring-1 ring-slate-200 shadow-[0_34px_80px_-30px_rgba(2,32,71,0.5),0_10px_24px_-12px_rgba(15,23,42,0.12)]">
<ShareCardMock />
</div>
<!-- export controls, slim -->
<div class="mt-4 flex flex-wrap items-center justify-between gap-3">
<div class="inline-flex items-center gap-1 rounded-md bg-slate-100 p-0.5">
<span class="h-7 px-3 inline-flex items-center rounded text-[12px] font-medium bg-sky-600 text-white shadow-sm">1:1</span>
<span class="h-7 px-3 inline-flex items-center rounded text-[12px] font-medium text-slate-500">3:2</span>
<span class="h-7 px-3 inline-flex items-center rounded text-[12px] font-medium text-slate-500">16:9</span>
</div>
<div class="flex items-center gap-2">
<span class="h-8 px-3 inline-flex items-center gap-1.5 rounded-md border border-slate-200 bg-white text-slate-700 text-[12px] font-medium">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
Copy
</span>
<span class="h-8 px-3 inline-flex items-center gap-1.5 rounded-md bg-slate-900 text-white text-[12px] font-medium">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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" y1="15" x2="12" y2="3"/></svg>
Download PNG
</span>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ============================================================
EXPORTS · 2-col spec sheet (warmup defaults pattern)
DRILL-DOWN · workspace KPI to one recipient (numbered rail)
============================================================ -->
<section class="bg-[color:var(--surface-1)]/40 border-b border-[color:var(--border)] py-20 md:py-28">
<section class="border-y border-[color:var(--border)] bg-[color:var(--surface-2)]/50 py-20 md:py-28">
<div class="container-page grid lg:grid-cols-[1fr_1.5fr] 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">Drill down</div>
<h2 class="text-[32px] md:text-[44px] font-semibold tracking-[-0.03em] leading-[1.05] text-heading">
Every number opens up.
</h2>
<p class="mt-4 text-[15.5px] text-foreground/70 leading-relaxed max-w-md">
A KPI you cannot explain is a KPI you cannot fix. Each headline number unfolds one level at a time, from the workspace strip all the way to the single recipient event behind it. No dead ends.
</p>
</div>
<div class="relative">
<div class="absolute left-[18px] top-3 bottom-3 w-px bg-[color:var(--border)]"></div>
<div class="space-y-3">
{drill.map((r, i) => (
<div class="grid grid-cols-[40px_1fr] gap-4 items-start">
<div class="relative">
<div class="w-9 h-9 rounded-full bg-white ring-1 ring-[color:var(--sky-2)] text-[12px] font-mono text-[#0369a1] inline-flex items-center justify-center font-semibold">
{String(i + 1).padStart(2, '0')}
</div>
</div>
<div class="rounded-[12px] bg-white ring-1 ring-[color:var(--border)] p-4 md:p-5" style="box-shadow: var(--shadow-sm);">
<div class="flex items-baseline justify-between gap-3">
<div class="text-[15px] font-semibold text-heading">{r.k}</div>
<div class="text-[11px] font-mono uppercase tracking-[0.14em] text-muted-foreground shrink-0">{r.v}</div>
</div>
<p class="mt-1.5 text-[13px] text-foreground/70 leading-relaxed">{r.d}</p>
</div>
</div>
))}
</div>
</div>
</div>
</section>
<!-- ============================================================
REALTIME · sticky narrative + live list (suppression pattern)
============================================================ -->
<section class="bg-white border-b border-[color:var(--border)] py-20 md:py-28">
<div class="container-page grid lg:grid-cols-[1fr_1.6fr] 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">Realtime</div>
<h2 class="text-[28px] md:text-[40px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading">
It updates while you watch.
</h2>
<p class="mt-4 text-[15px] text-foreground/70 leading-relaxed max-w-md">
Events fan out over a websocket the moment the consumer processes them, so the dashboard stays current on its own. No refresh button, no five-minute polling lag. It feels closer to a chat app than a report.
</p>
<span class="mt-6 inline-flex items-center gap-2 h-7 px-2.5 rounded-md bg-emerald-50 text-emerald-700 text-[11.5px] font-medium ring-1 ring-emerald-100">
<span class="relative inline-flex size-2">
<span class="motion-safe:animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-70"></span>
<span class="relative inline-flex size-2 rounded-full bg-emerald-500"></span>
</span>
Live by default
</span>
</div>
<div class="rounded-[14px] bg-white ring-1 ring-[color:var(--border)] overflow-hidden" style="box-shadow: var(--shadow-sm);">
{live.map((l) => (
<div class="flex items-start gap-3 px-6 py-4 border-b border-[color:var(--border)] last:border-b-0">
<span class="mt-1.5 size-2 rounded-full bg-emerald-500 shrink-0"></span>
<div>
<div class="text-[14px] font-semibold text-heading">{l.k}</div>
<p class="mt-0.5 text-[13px] text-foreground/65 leading-relaxed">{l.d}</p>
</div>
</div>
))}
</div>
</div>
</section>
<!-- ============================================================
DEFINITIONS · exactly what each number is + where it's computed
============================================================ -->
<section class="border-y border-[color:var(--border)] bg-[color:var(--surface-2)]/50 py-20 md:py-28">
<div class="container-page">
<div class="max-w-2xl mb-12">
<div class="text-[11px] uppercase tracking-[0.18em] font-mono text-muted-foreground mb-3">Definitions</div>
<h2 class="text-[32px] md:text-[44px] font-semibold tracking-[-0.03em] leading-[1.05] text-heading">
No mystery math.
</h2>
<p class="mt-4 text-[15.5px] text-foreground/70 leading-relaxed">
Every metric is a plain ratio over a denominator we name, computed in a codepath you can point at. Nothing is smoothed, weighted, or hidden behind a single blended "score".
</p>
</div>
<div class="grid md:grid-cols-2 gap-x-12 gap-y-px">
{definitions.map((d, i) => (
<div class="grid grid-cols-[1fr_auto] gap-6 py-5 border-b border-[color:var(--border)] items-baseline hover:bg-white/60 -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>
<div class="mt-2 font-mono text-[10.5px] text-[#0369a1]">{d.src}</div>
</div>
<div class="text-right">
<div class="text-[15px] md:text-[17px] font-semibold tracking-[-0.01em] text-heading font-mono whitespace-nowrap">{d.f}</div>
</div>
</div>
))}
</div>
<p class="mt-8 text-[11.5px] font-mono text-muted-foreground">
Mirrors the real dashboard: <span class="text-foreground/75">web/src/app/app/analytics/page.tsx</span>
</p>
</div>
</section>
<!-- ============================================================
EXPORTS · 2-col spec sheet
============================================================ -->
<section class="bg-white 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] font-mono text-[#0284c7] mb-3">Exports</div>
@@ -274,13 +418,13 @@ const faq = [
Your data is yours.
</h2>
<p class="mt-4 text-[15.5px] text-foreground/70 leading-relaxed">
Five ways to get the data out. Same event schema across all of them, versioned in the payload.
Five ways to get the data out, same event schema across all of them, versioned in the payload.
</p>
</div>
<div class="grid md:grid-cols-2 gap-x-12 gap-y-px">
{exports.map((d, i) => (
<div class="grid grid-cols-[1fr_auto] gap-6 py-6 border-b border-[color:var(--border)] items-baseline hover:bg-white/40 -mx-3 px-3 rounded-[6px] transition-colors">
<div class="grid grid-cols-[1fr_auto] gap-6 py-6 border-b border-[color:var(--border)] items-baseline hover:bg-[color:var(--surface-2)]/60 -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}
@@ -297,14 +441,14 @@ const faq = [
</section>
<!-- ============================================================
FAQ · grid-rows accordion
FAQ · sticky title + accordion
============================================================ -->
<section class="py-20 md:py-24">
<section class="border-y border-[color:var(--border)] bg-[color:var(--surface-2)]/50 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 reporting questions.
Five reporting questions.
</h2>
</div>
<div class="divide-y divide-[color:var(--border)]" data-faq>
@@ -312,8 +456,8 @@ const faq = [
<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 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">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
</span>
</button>
<div class="faq-panel grid grid-rows-[0fr] transition-[grid-template-rows] duration-300 ease-out">
@@ -345,5 +489,12 @@ const faq = [
</div>
</section>
<CTA />
<CTA
title="See where every number comes from."
description="Connect a mailbox and watch the first events land in the dashboard, live, within minutes."
primaryLabel="See your dashboard"
primaryHref="https://app.warmbly.com/register"
secondaryLabel="How deliverability works"
secondaryHref="/deliverability/"
/>
</Layout>