feat: add role and team-size onboarding questions and rework site SEO me

This commit is contained in:
Matthew Meszaros
2026-05-29 14:47:16 +00:00
parent 1d132ccd56
commit ff763c4483
60 changed files with 417 additions and 159 deletions
+32 -1
View File
@@ -13,6 +13,8 @@ type completeOnboardingRequest struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
ReferralSource string `json:"referral_source"`
Role string `json:"role"`
TeamSize string `json:"team_size"`
}
var validReferralSources = map[string]bool{
@@ -23,6 +25,25 @@ var validReferralSources = map[string]bool{
"other": true,
}
// Persona + team-size answers from the onboarding questionnaire. Both optional:
// when provided they must be one of these, otherwise they're stored as NULL.
var validRoles = map[string]bool{
"founder": true,
"sales": true,
"marketing": true,
"agency": true,
"recruiter": true,
"other": true,
}
var validTeamSizes = map[string]bool{
"just_me": true,
"2-10": true,
"11-50": true,
"51-200": true,
"200+": true,
}
func (h *Handler) CompleteOnboarding(c *gin.Context) {
var req completeOnboardingRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -45,6 +66,16 @@ func (h *Handler) CompleteOnboarding(c *gin.Context) {
return
}
if req.Role != "" && !validRoles[req.Role] {
errx.Handle(c, errx.New(errx.BadRequest, "Invalid role."))
return
}
if req.TeamSize != "" && !validTeamSizes[req.TeamSize] {
errx.Handle(c, errx.New(errx.BadRequest, "Invalid team size."))
return
}
userID := middleware.GetUserID(c)
uid, err := uuid.Parse(userID)
if err != nil {
@@ -52,7 +83,7 @@ func (h *Handler) CompleteOnboarding(c *gin.Context) {
return
}
if xerr := h.UserService.CompleteOnboarding(c.Request.Context(), uid, req.FirstName, req.LastName, req.ReferralSource); xerr != nil {
if xerr := h.UserService.CompleteOnboarding(c.Request.Context(), uid, req.FirstName, req.LastName, req.ReferralSource, req.Role, req.TeamSize); xerr != nil {
errx.Handle(c, xerr)
return
}
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"github.com/warmbly/warmbly/internal/errx"
)
func (s *userService) CompleteOnboarding(ctx context.Context, userID uuid.UUID, firstName, lastName, referralSource string) *errx.Error {
if err := s.userRepository.UpdateOnboarding(ctx, userID, firstName, lastName, referralSource); err != nil {
func (s *userService) CompleteOnboarding(ctx context.Context, userID uuid.UUID, firstName, lastName, referralSource, role, teamSize string) *errx.Error {
if err := s.userRepository.UpdateOnboarding(ctx, userID, firstName, lastName, referralSource, role, teamSize); err != nil {
return errx.InternalError()
}
+1 -1
View File
@@ -13,7 +13,7 @@ import (
type UserService interface {
SaveUser(ctx context.Context, user *models.User) *errx.Error
GetUser(ctx context.Context, userID uuid.UUID) (*models.User, *errx.Error)
CompleteOnboarding(ctx context.Context, userID uuid.UUID, firstName, lastName, referralSource string) *errx.Error
CompleteOnboarding(ctx context.Context, userID uuid.UUID, firstName, lastName, referralSource, role, teamSize string) *errx.Error
}
type userService struct {
@@ -2,5 +2,6 @@ DROP TABLE IF EXISTS decision_log;
DROP TABLE IF EXISTS provisioning_jobs;
DROP TABLE IF EXISTS provisioning_policy;
DROP TABLE IF EXISTS provisioning_templates;
DROP TABLE IF EXISTS worker_profiles;
-- worker_profiles is owned by migration 000029, not this one — do NOT drop it
-- here or rolling back provisioning would destroy the worker-credentials table.
DROP TABLE IF EXISTS cloud_credentials;
@@ -27,22 +27,17 @@ CREATE TABLE IF NOT EXISTS cloud_credentials (
);
-- ---------------------------------------------------------------------------
-- Worker env profiles. env_template is the JSONB bundle that gets rendered
-- into /etc/warmbly/worker.env during install. tier + egress_kind constrain
-- which mailboxes the resulting workers can carry.
-- NOTE: worker_profiles is intentionally NOT created here. It is owned by
-- migration 000029 (worker_credentials), which models the Kafka/Redis/AWS
-- connection bundle that pg_credentials.go reads and writes. An earlier merge
-- accidentally re-declared a second, conflicting worker_profiles in this file
-- (env_template/tier/egress_kind); because it used CREATE TABLE IF NOT EXISTS
-- it silently no-op'd against 029's table, so those columns never existed and
-- nothing reads them. The provisioning surface only needs worker_profiles to
-- EXIST so the provisioning_templates.worker_profile_id FK below can reference
-- it; the tier/egress_kind the provisioner cares about live on
-- provisioning_templates itself.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS worker_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
description TEXT,
env_template JSONB NOT NULL DEFAULT '{}'::jsonb,
image_tag TEXT NOT NULL DEFAULT 'ghcr.io/warmbly/worker:latest',
tier TEXT NOT NULL CHECK (tier IN ('shared_free','shared_premium','dedicated')),
egress_kind TEXT NOT NULL DEFAULT 'cold_smtp'
CHECK (egress_kind IN ('cold_smtp','oauth_api','warmup_only')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ---------------------------------------------------------------------------
-- Provisioning templates: saved configs for one-click provisioning.
@@ -0,0 +1,3 @@
ALTER TABLE users
DROP COLUMN IF EXISTS team_size,
DROP COLUMN IF EXISTS job_role;
@@ -0,0 +1,7 @@
-- Multi-step onboarding questionnaire: the user's role/persona and team size,
-- captured after the profile + workspace-naming steps. Both nullable, so any
-- pre-existing user simply keeps NULL until they complete onboarding again.
-- (Named job_role to avoid colliding with the org-membership "role" concept.)
ALTER TABLE users
ADD COLUMN IF NOT EXISTS job_role VARCHAR(50),
ADD COLUMN IF NOT EXISTS team_size VARCHAR(20);
+4 -4
View File
@@ -21,7 +21,7 @@ type UserRepository interface {
GetUser(ctx context.Context, id uuid.UUID) (*models.User, error)
GetUserByEmail(ctx context.Context, email string) (*models.User, error)
SetFreeTrialUsed(ctx context.Context, userID uuid.UUID) error
UpdateOnboarding(ctx context.Context, userID uuid.UUID, firstName, lastName, referralSource string) error
UpdateOnboarding(ctx context.Context, userID uuid.UUID, firstName, lastName, referralSource, role, teamSize string) error
UpdateAvatar(ctx context.Context, userID uuid.UUID, avatarURL *string) error
// GetBanState returns the user's ban_scope bitmask (0 = not
@@ -150,9 +150,9 @@ func (r *userRepository) SetFreeTrialUsed(ctx context.Context, userID uuid.UUID)
return err
}
func (r *userRepository) UpdateOnboarding(ctx context.Context, userID uuid.UUID, firstName, lastName, referralSource string) error {
const q = `UPDATE users SET first_name=$2, last_name=$3, referral_source=$4, onboarding_completed_at=NOW(), updated_at=NOW() WHERE id=$1`
_, err := r.DB.Exec(ctx, q, userID, firstName, lastName, referralSource)
func (r *userRepository) UpdateOnboarding(ctx context.Context, userID uuid.UUID, firstName, lastName, referralSource, role, teamSize string) error {
const q = `UPDATE users SET first_name=$2, last_name=$3, referral_source=$4, job_role=NULLIF($5,''), team_size=NULLIF($6,''), onboarding_completed_at=NOW(), updated_at=NOW() WHERE id=$1`
_, err := r.DB.Exec(ctx, q, userID, firstName, lastName, referralSource, role, teamSize)
return err
}
+6 -1
View File
@@ -6,7 +6,12 @@ import sitemap from '@astrojs/sitemap';
// https://astro.build/config
export default defineConfig({
site: 'https://warmbly.com',
integrations: [sitemap()],
integrations: [
sitemap({
// The 404 page is noindex and should never appear in the sitemap.
filter: (page) => !page.includes('/404'),
}),
],
vite: {
plugins: [tailwindcss()],
},
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 666 KiB

+5 -52
View File
@@ -1,6 +1,6 @@
/**
* Generate every favicon / app-icon / social-card variant from the
* source social avatar (public/brand/social-avatar.jpg).
* Generate every favicon / app-icon variant from the source social avatar
* (public/brand/social-avatar.jpg).
*
* Outputs land in public/:
* favicon-16x16.png, favicon-32x32.png, favicon-48x48.png,
@@ -8,7 +8,9 @@
* apple-touch-icon.png (180 square, iOS rounds it)
* web-app-manifest-192x192.png
* web-app-manifest-512x512.png
* og-image.png (1200x630 OG / Twitter card)
*
* The 1200x630 OG / Twitter card (public/og-image.png) is generated
* separately from scripts/og-image.html — see scripts/render-og.py.
*
* Run with: node scripts/gen-icons.mjs
*/
@@ -55,52 +57,6 @@ async function rounded(size, outPath, radiusRatio = 0.22) {
console.log('wrote', outPath, `(rounded r=${radiusRatio})`);
}
async function ogCard(outPath) {
// 1200x630 sky-blue canvas with the avatar centered on the left half
// and room on the right for typography handled by the platform.
const W = 1200;
const H = 630;
const AVATAR = 460;
const avatar = await sharp(source)
.resize(AVATAR, AVATAR, { fit: 'cover' })
.png()
.toBuffer();
const avatarRounded = await sharp(avatar)
.composite([{ input: roundedMask(AVATAR, 0.18), blend: 'dest-in' }])
.png()
.toBuffer();
// sky gradient background, matching the site's sky tokens
const bg = Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}">
<defs>
<linearGradient id="g" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#0284c7"/>
<stop offset="60%" stop-color="#0369a1"/>
<stop offset="100%" stop-color="#075985"/>
</linearGradient>
</defs>
<rect width="${W}" height="${H}" fill="url(#g)"/>
</svg>`
);
await sharp(bg)
.png()
.composite([
{
input: avatarRounded,
top: Math.round((H - AVATAR) / 2),
left: 95,
},
])
.png({ quality: 92 })
.toFile(outPath);
console.log('wrote', outPath);
}
await Promise.all([
// Tab favicons: rounded so the icon reads as a soft chip in the tab strip.
rounded(16, resolve(PUB, 'favicon-16x16.png'), 0.22),
@@ -115,9 +71,6 @@ await Promise.all([
// for adaptive (maskable) usage.
square(192, resolve(PUB, 'web-app-manifest-192x192.png')),
square(512, resolve(PUB, 'web-app-manifest-512x512.png')),
// Social cards
ogCard(resolve(PUB, 'og-image.png')),
]);
console.log('done.');
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+97
View File
@@ -0,0 +1,97 @@
<!doctype html>
<!--
Source for public/og-image.png (the 1200x630 Open Graph / Twitter card).
This mirrors the marketing hero (src/components/HeroAtmosphere.astro) 1:1:
the exact .sky-base deep-sky gradient, the same six painterly cloud WebPs in
the same layered arrangement, the warm sun glow and the twinkle. The only
foreground is the Warmbly lockup, centered, small and balanced, no shadow,
sitting on the clearer deep-blue centre so it reads cleanly.
Re-render to PNG with headless Chromium at a 1200x630 viewport:
/tmp/pwvenv/bin/python scripts/render-og.py
Fonts are committed under scripts/og-assets/; clouds live in public/backdrops/.
-->
<html lang="en">
<head>
<meta charset="utf-8" />
<style>
@font-face { font-family: 'Inter'; font-weight: 700; src: url('./og-assets/inter-latin-700-normal.woff2') format('woff2'); }
@font-face { font-family: 'Inter'; font-weight: 800; src: url('./og-assets/inter-latin-800-normal.woff2') format('woff2'); }
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 1200px; height: 630px; overflow: hidden;
font-family: 'Inter', system-ui, sans-serif;
-webkit-font-smoothing: antialiased; text-rendering: geometricPrecision;
}
.card { position: relative; width: 1200px; height: 630px; overflow: hidden; }
/* === hero atmosphere, lifted verbatim from src/styles/global.css === */
.sky-base {
position: absolute; inset: 0;
background: radial-gradient(ellipse 140% 140% at 72% 25%,
#7dd3fc 0%, #38bdf8 18%, #0ea5e9 36%, #0284c7 58%, #075985 82%);
}
.sky-breathe {
position: absolute; inset: 0; opacity: 0.6;
background: radial-gradient(ellipse 140% 140% at 72% 25%,
rgba(186,230,253,0.85) 0%, rgba(56,189,248,0.42) 18%, rgba(14,165,233,0.18) 36%, transparent 60%);
}
.sky-haze-bottom {
position: absolute; inset: auto 0 0 0; height: 55%;
background: linear-gradient(to top, rgba(125,211,252,0.18) 0%, rgba(56,189,248,0.08) 35%, transparent 100%);
}
.sun-glow {
position: absolute; top: -110px; right: 90px; width: 620px; height: 620px;
border-radius: 9999px; filter: blur(42px);
background: radial-gradient(circle, rgba(253,230,138,0.30) 0%, rgba(253,186,116,0.10) 32%, rgba(186,230,253,0.04) 55%, transparent 70%);
}
/* a soft radial keeps the centre deep enough for the white lockup to read */
.center-deepen {
position: absolute; inset: 0;
background: radial-gradient(ellipse 56% 64% at 50% 52%, rgba(3,52,84,0.30) 0%, rgba(3,52,84,0.10) 42%, transparent 66%);
}
.cloud { position: absolute; height: auto; user-select: none; }
.lockup {
position: absolute; inset: 0; z-index: 3;
display: flex; align-items: center; justify-content: center; gap: 22px;
}
.lockup svg { height: 74px; width: auto; display: block; }
.word { font-weight: 800; font-size: 48px; letter-spacing: -1.8px; color: #fff; line-height: 1; }
</style>
</head>
<body>
<div class="card">
<div class="sky-base"></div>
<div class="sky-breathe"></div>
<div class="sky-haze-bottom"></div>
<div class="sun-glow"></div>
<!-- six painterly clouds, same variants/arrangement as the hero, framing
the centre so the lockup sits on clear sky -->
<img class="cloud" src="../public/backdrops/cloud-5.webp" style="top: -130px; left: -130px; width: 600px; opacity: 0.95;" alt="" />
<img class="cloud" src="../public/backdrops/cloud-2.webp" style="top: -110px; right: -120px; width: 560px; opacity: 0.92;" alt="" />
<img class="cloud" src="../public/backdrops/cloud-3.webp" style="top: 150px; left: -120px; width: 380px; opacity: 0.66;" alt="" />
<img class="cloud" src="../public/backdrops/cloud-4.webp" style="top: 180px; right: -110px; width: 360px; opacity: 0.62;" alt="" />
<img class="cloud" src="../public/backdrops/cloud-1.webp" style="bottom: -150px; left: 90px; width: 420px; opacity: 0.78;" alt="" />
<img class="cloud" src="../public/backdrops/cloud-3.webp" style="bottom: -140px; right: 120px; width: 360px; opacity: 0.6;" alt="" />
<div class="center-deepen"></div>
<!-- twinkle, like the hero -->
<svg style="position:absolute; top:120px; left:430px; width:16px; height:16px; z-index:2;" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path d="M7 1 L8.5 5.5 L13 7 L8.5 8.5 L7 13 L5.5 8.5 L1 7 L5.5 5.5 Z" fill="rgba(255,255,255,0.9)" />
</svg>
<div class="lockup">
<svg viewBox="0 0 746 764" xmlns="http://www.w3.org/2000/svg" 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="#ffffff" />
</svg>
<span class="word">Warmbly</span>
</div>
</div>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env python3
"""Render scripts/og-image.html to public/og-image.png at 1200x630.
Run with the project's Playwright environment, e.g.:
/tmp/pwvenv/bin/python scripts/render-og.py
"""
import pathlib
from playwright.sync_api import sync_playwright
HERE = pathlib.Path(__file__).resolve().parent
SRC = HERE / "og-image.html"
OUT = HERE.parent / "public" / "og-image.png"
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1200, "height": 630}, device_scale_factor=1)
page.goto(SRC.as_uri())
page.wait_for_timeout(400) # let webfonts + cloud images settle
page.locator(".card").screenshot(path=str(OUT))
browser.close()
print("wrote", OUT)
+18 -5
View File
@@ -7,6 +7,10 @@ interface Props {
title: string;
description?: string;
ogImage?: string;
/** og:type — 'website' for most pages, 'article' for Learn essays */
ogType?: string;
/** Extra structured data injected per-page (e.g. BlogPosting, SoftwareApplication) */
jsonLd?: Record<string, unknown> | Record<string, unknown>[];
noIndex?: boolean;
/** Toggle "marketing chrome" header/footer */
bare?: boolean;
@@ -16,10 +20,14 @@ const {
title,
description = 'Warmbly is the deliverability platform for cold email. Warm your inboxes, send at scale, keep your reputation clean.',
ogImage = '/og-image.png',
ogType = 'website',
jsonLd,
noIndex = false,
bare = false,
} = Astro.props;
const extraJsonLd = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
const siteOrigin = (Astro.site ?? new URL('https://warmbly.com')).toString().replace(/\/$/, '');
const canonical = new URL(Astro.url.pathname, Astro.site ?? 'https://warmbly.com').toString();
const ogImageAbsolute = new URL(ogImage, Astro.site ?? 'https://warmbly.com').toString();
@@ -28,6 +36,7 @@ const ogImageAbsolute = new URL(ogImage, Astro.site ?? 'https://warmbly.com').to
const organizationJsonLd = {
'@context': 'https://schema.org',
'@type': 'Organization',
'@id': `${siteOrigin}#org`,
name: 'Warmbly',
legalName: 'Mindroot Ltd',
url: siteOrigin,
@@ -81,7 +90,6 @@ const websiteJsonLd = {
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
{noIndex && <meta name="robots" content="noindex,nofollow" />}
<!-- Favicons: rounded sky chip variants generated from the social avatar.
Sized PNGs are preferred by modern browsers; the .ico is a legacy
@@ -104,16 +112,18 @@ const websiteJsonLd = {
<meta name="format-detection" content="telephone=no" />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:type" content={ogType} />
<meta property="og:site_name" content="Warmbly" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<meta property="og:locale" content="en_US" />
<meta property="og:image" content={ogImageAbsolute} />
<meta property="og:image:secure_url" content={ogImageAbsolute} />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="Warmbly" />
<meta property="og:image:alt" content="Warmbly: the open-source cold email platform" />
<!-- Twitter / X -->
<meta name="twitter:card" content="summary_large_image" />
@@ -122,7 +132,7 @@ const websiteJsonLd = {
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={ogImageAbsolute} />
<meta name="twitter:image:alt" content="Warmbly" />
<meta name="twitter:image:alt" content="Warmbly: the open-source cold email platform" />
<!-- Crawler hints -->
<meta name="robots" content={noIndex ? 'noindex,nofollow' : 'index,follow,max-image-preview:large,max-snippet:-1'} />
@@ -130,9 +140,12 @@ const websiteJsonLd = {
<link rel="sitemap" href="/sitemap-index.xml" />
<!-- Structured data: Organization + WebSite -->
<!-- Structured data: Organization + WebSite (+ optional per-page schema) -->
<script type="application/ld+json" is:inline set:html={JSON.stringify(organizationJsonLd)} />
<script type="application/ld+json" is:inline set:html={JSON.stringify(websiteJsonLd)} />
{extraJsonLd.map((schema) => (
<script type="application/ld+json" is:inline set:html={JSON.stringify(schema)} />
))}
</head>
<body class="min-h-screen flex flex-col antialiased">
{!bare && <Header />}
+18 -1
View File
@@ -29,8 +29,25 @@ const related = [
];
const path = Astro.url.pathname;
const next = related.filter((r) => r.href !== path).slice(0, 2);
// BlogPosting structured data for each guide. Helps the essays surface as
// articles rather than generic pages, and ties them back to the org.
const siteOrigin = (Astro.site ?? new URL('https://warmbly.com')).toString().replace(/\/$/, '');
const canonical = new URL(path, Astro.site ?? 'https://warmbly.com').toString();
const articleJsonLd = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: title,
description: description ?? title,
image: `${siteOrigin}/og-image.png`,
...(updated ? { dateModified: updated, datePublished: updated } : {}),
author: { '@id': `${siteOrigin}#org` },
publisher: { '@id': `${siteOrigin}#org` },
mainEntityOfPage: { '@type': 'WebPage', '@id': canonical },
isAccessibleForFree: true,
};
---
<Layout title={title} description={description ?? title}>
<Layout title={title} description={description ?? title} ogType="article" jsonLd={articleJsonLd}>
<!-- TOP BAR -->
<div class="bg-white border-b border-[color:var(--border)]">
<div class="container-page py-3 flex items-center justify-between text-[11.5px] font-mono">
+17 -34
View File
@@ -1,50 +1,33 @@
---
import Layout from '../layouts/Layout.astro';
import Cloud from '../components/Cloud.astro';
import HeroAtmosphere from '../components/HeroAtmosphere.astro';
import Icon from '../components/Icon.astro';
---
<Layout title="Not found · Warmbly" description="The page you are looking for does not exist." noIndex>
<section class="relative isolate overflow-hidden text-white min-h-[80vh] flex items-center" style="background: radial-gradient(ellipse 130% 140% at 50% 35%, #0284c7 0%, #0369a1 25%, #075985 50%, #0c4a6e 80%, #0a3d5c 100%);">
<div class="absolute -top-10 right-[-40px] w-[480px] opacity-35 pointer-events-none cloud-drift cloud-1" style="mix-blend-mode: screen;">
<Cloud variant={5} opacity={1} />
</div>
<div class="absolute -bottom-16 left-[-80px] w-[420px] opacity-30 pointer-events-none cloud-drift cloud-2" style="mix-blend-mode: screen;">
<Cloud variant={3} opacity={1} />
</div>
<div class="absolute top-1/4 left-1/4 w-[300px] opacity-22 pointer-events-none cloud-drift cloud-3" style="mix-blend-mode: screen;">
<Cloud variant={2} opacity={1} />
</div>
<Layout title="Page Not Found | Warmbly" description="The page you are looking for does not exist." noIndex>
<section class="relative isolate overflow-hidden text-white min-h-[88vh] flex items-center justify-center">
<!-- Same airy sky as the marketing hero -->
<HeroAtmosphere />
<div class="container-page relative text-center py-16">
<div class="text-[10.5px] uppercase tracking-[0.18em] text-white/55 font-mono mb-4">Error 404</div>
<div class="text-[120px] md:text-[180px] font-semibold tracking-[-0.05em] leading-[0.85] text-white/95 select-none">404</div>
<h1 class="mt-8 text-[24px] md:text-[32px] font-semibold tracking-[-0.02em] leading-[1.1] text-white">
We could not find that page.
<div class="container-page relative text-center py-24">
<div class="text-[110px] sm:text-[140px] md:text-[168px] font-semibold tracking-[-0.05em] leading-[0.8] text-white select-none">
404
</div>
<h1 class="mt-6 text-[26px] md:text-[34px] font-semibold tracking-[-0.025em] text-white">
Page not found
</h1>
<p class="mt-3 text-[14.5px] text-white/70 max-w-md mx-auto">
It was moved, never shipped, or got quarantined by the deliverability layer. We are sorry about that.
<p class="mt-4 text-[15px] md:text-[16.5px] text-white/75 max-w-md mx-auto leading-relaxed">
The page you are looking for doesn't exist or has moved.
</p>
<div class="mt-9 flex flex-wrap items-center justify-center gap-3">
<a href="/" class="inline-flex h-11 px-5 items-center gap-2 rounded-[10px] text-[14.5px] font-medium bg-white text-[color:var(--sky-7)] hover:bg-white/95">
<a href="/" class="inline-flex h-11 px-5 items-center gap-2 rounded-[10px] text-[14.5px] font-medium bg-white hover:bg-white/95 transition-colors shadow-[0_10px_24px_-10px_rgba(8,47,73,0.55)]" style="color:#0c4a6e;">
<Icon name="arrowRight" size={14} class="rotate-180" /> Back to home
</a>
<a href="/learn/" 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/30 text-white hover:bg-white/20">
Browse learn
<a href="/learn/" 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/30 text-white hover:bg-white/20 transition-colors">
Browse the guides
</a>
</div>
<div class="mt-12 flex flex-wrap items-center justify-center gap-x-5 gap-y-2 text-[12px] font-mono text-white/55">
{[
['Warmup', '/warmup/'],
['Pricing', '/pricing/'],
['Changelog', '/changelog/'],
['Contact', '/contact/'],
['Status', 'https://status.warmbly.com'],
].map(([l, h]) => (
<a href={h} class="hover:text-white">{l}</a>
))}
</div>
</div>
</section>
</Layout>
+1 -1
View File
@@ -122,7 +122,7 @@ const involve = [
];
---
<Layout
title="About · Warmbly"
title="About - The Open Cold Email Engine | Warmbly"
description="Warmbly is an open-source email warmup and cold outreach platform. Control plane and distributed workers. Per-mailbox safety. Public roadmap."
>
<!-- ============================================================
+1 -1
View File
@@ -39,7 +39,7 @@ const faq = [
];
---
<Layout
title="Analytics · Warmbly"
title="Deliverability Analytics & Placement | Warmbly"
description="Deliverability-aware analytics: per-mailbox placement, complaint and bounce rates, warmup health, sequence conversion. No vanity opens."
>
<!-- ============================================================
+1 -1
View File
@@ -96,7 +96,7 @@ const assets = [
];
---
<Layout
title="Brand · Warmbly"
title="Brand Assets & Guidelines | Warmbly"
description="The Warmbly visual identity. Wordmark, mark, sky palette, typography, and usage guidance."
>
<!-- ============================================================
+1 -1
View File
@@ -58,7 +58,7 @@ const faq = [
];
---
<Layout
title="Campaigns · Warmbly"
title="Cold Email Campaigns & Sequences | Warmbly"
description="Multi-step sequences with branching, A/B variants, business-hour scheduling and per-mailbox distribution."
>
<!-- ============================================================
+1 -1
View File
@@ -105,7 +105,7 @@ const changedCount = counts.changed;
const fixedCount = counts.fixed;
---
<Layout
title="Changelog · Warmbly"
title="Changelog & Product Updates | Warmbly"
description="Everything we shipped, changed, and fixed at Warmbly. The actual history of the platform, dated and tagged."
>
<!-- ============================================================
+1 -1
View File
@@ -76,7 +76,7 @@ const faqs = [
];
---
<Layout
title="Contact · Warmbly"
title="Contact Sales & Support | Warmbly"
description="Reach the team or sales directly. Two inboxes, one owner each, with published response times."
>
<!-- =====================================================
+1 -1
View File
@@ -68,7 +68,7 @@ const faq = [
];
---
<Layout
title="CRM · Warmbly"
title="Built-In CRM for Outbound | Warmbly"
description="A light CRM that does not pretend to be Salesforce. Pipelines, deals, tasks, contacts and templates that live next to your sending."
>
<!-- ============================================================
+1 -1
View File
@@ -234,7 +234,7 @@ const numbers = [
];
---
<Layout
title="Deliverability · Warmbly"
title="Email Deliverability, Mailbox-First | Warmbly"
description="Mailbox-first deliverability. Per-mailbox caps, signed warmup tokens, pool isolation, and auto-quarantine bands that act weeks before providers do."
>
<!-- ============================================================
+1 -1
View File
@@ -79,7 +79,7 @@ const faq = [
];
---
<Layout
title="API and webhooks · Warmbly"
title="Developer API & Webhooks | Warmbly"
description="REST API with idempotency keys, scoped keys, HMAC-signed webhooks, and a dead-letter view. The same surface the dashboard runs on."
>
<!-- ============================================================
+2 -2
View File
@@ -57,8 +57,8 @@ const groups = [
];
---
<Layout
title="FAQ · Warmbly"
description="Common questions about warmup, sending, the inbox, security and billing."
title="Frequently Asked Questions | Warmbly"
description="Answers to common questions about email warmup, cold email sending limits, deliverability, mailbox connections, security, and billing on Warmbly."
>
<!-- HERO with sticky-nav teaser -->
<section class="relative isolate overflow-hidden text-white" style="background: radial-gradient(ellipse 130% 140% at 72% 28%, #0284c7 0%, #0369a1 25%, #075985 50%, #0c4a6e 80%, #0a3d5c 100%);">
+1 -1
View File
@@ -51,7 +51,7 @@ const faq = [
];
---
<Layout
title="Unified inbox · Warmbly"
title="Unified Inbox for Every Reply | Warmbly"
description="Every reply, bounce, OOO and complaint across every mailbox in one console. Classified on arrival. Assignable. Searchable."
>
<!-- ============================================================
+24 -2
View File
@@ -90,10 +90,32 @@ const plans = [
],
},
];
// SoftwareApplication schema for the product itself — helps Google understand
// Warmbly is a SaaS tool with a free tier, not just a marketing page.
const softwareJsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: 'Warmbly',
applicationCategory: 'BusinessApplication',
applicationSubCategory: 'Email deliverability and cold outreach',
operatingSystem: 'Web',
url: 'https://warmbly.com',
description:
'Email warmup and cold email platform. Warm new mailboxes, send cold outreach at scale, and protect sender reputation with per-mailbox safety caps and deliverability tracking.',
publisher: { '@id': 'https://warmbly.com#org' },
offers: {
'@type': 'Offer',
price: '0',
priceCurrency: 'USD',
description: 'Free plan available, with paid plans for higher volume.',
},
};
---
<Layout
title="Warmbly · cold email that actually lands"
description="Warmbly is the deliverability platform for cold email. Warm your mailboxes, send at scale, keep your reputation clean across hundreds of mailboxes."
title="Open-Source Cold Email & Warmup Platform | Warmbly"
description="Warmbly is the open-source platform for cold email: warm your mailboxes, send campaigns, and triage every reply. Run it on our cloud or self-host the whole thing."
jsonLd={softwareJsonLd}
>
<!-- ============================================================
HERO — deep sky with drifting clouds, white text,
+1 -1
View File
@@ -189,7 +189,7 @@ const faq = [
];
---
<Layout
title="Integrations · Warmbly"
title="Integrations, Webhooks & API | Warmbly"
description="CRM, automation, notifications, meetings, and data integrations for Warmbly. Plus a signed webhook stream and developer API."
>
<!-- HERO -->
+1 -1
View File
@@ -2,7 +2,7 @@
import Learn from '../../layouts/Learn.astro';
---
<Learn
title="Cold email rules across regions"
title="Cold Email Rules & Compliance | Warmbly"
description="CAN-SPAM, GDPR, CASL, PECR. what each actually says, and how to send cold mail compliantly without hiring a lawyer."
reading="10 min read"
updated="2026-05-02"
+1 -1
View File
@@ -26,7 +26,7 @@ const related = [
const path = Astro.url.pathname;
---
<Layout
title="The deliverability handbook · Warmbly"
title="The Deliverability Handbook | Warmbly"
description="A full mental model for getting cold mail into the inbox, and keeping it there. Authentication, warmup, volume, reputation."
>
<!-- ============================================================
+1 -1
View File
@@ -2,7 +2,7 @@
import Learn from '../../layouts/Learn.astro';
---
<Learn
title="Email warmup, properly explained"
title="Email Warmup - How It Works | Warmbly"
description="Why warmup exists, how providers actually read it, and how to do it without faking signals."
reading="11 min read"
updated="2026-05-12"
+2 -2
View File
@@ -2,8 +2,8 @@
import Learn from '../../layouts/Learn.astro';
---
<Learn
title="Glossary"
description="Deliverability and cold-email vocabulary, defined plainly."
title="Cold Email Glossary | Warmbly"
description="Plain-English definitions of cold email and deliverability terms: SPF, DKIM, DMARC, inbox placement, sender reputation, warmup, bounce and complaint rate."
reading="5 min read"
updated="2026-04-12"
>
+1 -1
View File
@@ -2,7 +2,7 @@
import Learn from '../../layouts/Learn.astro';
---
<Learn
title="Inbox placement, not just delivery"
title="Inbox Placement vs. Delivery | Warmbly"
description="Delivery is a 200 OK from an SMTP server. Placement is whether the recipient actually sees the message."
reading="8 min read"
updated="2026-04-22"
+1 -1
View File
@@ -90,7 +90,7 @@ const levelTone = (l: string) => {
};
---
<Layout
title="Learn · Warmbly"
title="Cold Email & Deliverability Guides | Warmbly"
description="Long-form guides on email warmup, deliverability, authentication, cold email rules and reputation recovery. Written by the team."
>
<!-- ============================================================
@@ -2,7 +2,7 @@
import Learn from '../../layouts/Learn.astro';
---
<Learn
title="Recovering a burned mailbox"
title="Sender Reputation Recovery | Warmbly"
description="A step-by-step plan to bring a quarantined or low-placement mailbox back to healthy without making things worse."
reading="9 min read"
updated="2026-05-18"
+1 -1
View File
@@ -2,7 +2,7 @@
import Learn from '../../layouts/Learn.astro';
---
<Learn
title="SPF, DKIM and DMARC, end to end"
title="SPF, DKIM & DMARC Setup | Warmbly"
description="The three authentication records every cold-email sender needs, the alignment rules nobody explains, and the practical step-by-step setup."
reading="12 min read"
updated="2026-04-30"
+1 -1
View File
@@ -2,7 +2,7 @@
import Learn from '../../layouts/Learn.astro';
---
<Learn
title="Warmup pools: free, premium, dedicated"
title="How Warmup Pools Work | Warmbly"
description="How shared warmup pools actually work, why pool quality matters more than pool size, and what 'dedicated' should and shouldn't mean."
reading="7 min read"
updated="2026-04-19"
+1 -1
View File
@@ -126,7 +126,7 @@ const matrix = [
const headers = ['Starter', 'Grow', 'Business', 'Enterprise'];
---
<Layout
title="Pricing · Warmbly"
title="Pricing - Unlimited Mailboxes & Warmup | Warmbly"
description="Four plans, mailbox-based. Pay monthly or save 20% annually. Unlimited mailboxes and unlimited warmup on every paid plan."
>
<!-- ============================================================
+2 -2
View File
@@ -18,8 +18,8 @@ const sections = [
];
---
<Legal
title="Privacy policy"
description="What Warmbly collects, why, how it is protected, and the choices you have."
title="Privacy Policy | Warmbly"
description="How Warmbly collects, uses, stores, and protects your personal data and mailbox information. Read our full privacy policy and the choices you have."
effective="2026-05-27"
updated="2026-05-27"
sections={sections}
+1 -1
View File
@@ -88,7 +88,7 @@ const columns = [
];
---
<Layout
title="Roadmap · Warmbly"
title="Public Roadmap | Warmbly"
description="What we just shipped, what we are building, and what we are researching. The Warmbly roadmap is public on purpose."
>
<!-- ============================================================
+1 -1
View File
@@ -55,7 +55,7 @@ const faq = [
---
<Layout
title="Sending engine · Warmbly"
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."
>
<!-- ============================================================
+2 -2
View File
@@ -23,8 +23,8 @@ const sections = [
];
---
<Legal
title="Terms of service"
description="The agreement between you and Mindroot Ltd, the company behind Warmbly."
title="Terms of Service | Warmbly"
description="The terms that govern your use of Warmbly: acceptable use for cold email sending, billing, account responsibilities, and service limitations."
effective="2026-05-27"
updated="2026-05-27"
sections={sections}
+1 -1
View File
@@ -170,7 +170,7 @@ const disclosure = [
];
---
<Layout
title="Trust · Warmbly"
title="Security, Privacy & Trust | Warmbly"
description="How Warmbly protects your mailbox, your data and your reputation. Envelope encryption with AWS KMS, worker isolation, abuse controls and vulnerability reporting."
>
+1 -1
View File
@@ -84,7 +84,7 @@ const faq = [
];
---
<Layout
title="Agencies · Warmbly"
title="Outbound for Agencies & Clients | Warmbly"
description="Run outbound for many clients on many domains. Workspace-per-client isolation, per-client deliverability reporting and per-workspace billing."
>
<!-- ============================================================
+1 -1
View File
@@ -128,7 +128,7 @@ const faq = [
];
---
<Layout
title="Founders · Warmbly"
title="Founder-Led Outreach | Warmbly"
description="Run cold outreach as a founder without burning the inbox you actually run the company from. Sub-domain, 4 mailboxes, plain text, and warmup the right way."
>
<!-- ============================================================
+1 -1
View File
@@ -104,7 +104,7 @@ const faq = [
];
---
<Layout
title="Fundraising · Warmbly"
title="Investor Outreach for Fundraising | Warmbly"
description="Plan a focused cold investor sequence that lands in the inbox. Warm the mailbox 4 to 6 weeks ahead, send a clean two-step sequence, then watch reputation as the raise runs."
>
<!-- ============================================================
+2 -2
View File
@@ -43,8 +43,8 @@ const cases = [
];
---
<Layout
title="Use cases · Warmbly"
description="How founders, agencies, sales teams, recruiters and fundraisers use Warmbly."
title="Use Cases - Who Warmbly Is For | Warmbly"
description="How agencies, sales teams, founders, recruiters, and fundraisers use Warmbly to warm mailboxes and run cold email that lands. Find your playbook."
>
<!-- HERO -->
<section class="relative isolate overflow-hidden text-white" style="background: radial-gradient(ellipse 130% 140% at 72% 28%, #0284c7 0%, #0369a1 25%, #075985 50%, #0c4a6e 80%, #0a3d5c 100%);">
+1 -1
View File
@@ -113,7 +113,7 @@ const tipsForRecruiters = [
];
---
<Layout
title="For recruiters · Warmbly"
title="Candidate Outreach for Recruiters | Warmbly"
description="Personalised candidate outreach from each recruiter's own mailbox. Plain text, short sequences, suppression hygiene that protects the agency brand."
>
<!-- ============================================================
+1 -1
View File
@@ -148,7 +148,7 @@ const faq: [string, string][] = [
const capRatio = (today: number, cap: number) => Math.min(100, Math.round((today / cap) * 100));
---
<Layout
title="Sales teams · Warmbly"
title="Outreach for Sales Teams | Warmbly"
description="Distribute outbound across rep mailboxes with round-robin sender pools, route positive replies to the right AE, and keep CRM in sync."
>
<!-- ============================================================
+1 -1
View File
@@ -80,7 +80,7 @@ for (let wk = 0; wk < 5; wk++) {
}
---
<Layout
title="Warmup · Warmbly"
title="Email Warmup for Sender Reputation | Warmbly"
description="Reputation-safe warmup pools. Verification tokens, gradual ramps, and quarantine bands stricter than provider enforcement."
>
<!-- ============================================================
+1 -1
View File
@@ -10,7 +10,7 @@
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Warmbly App</title>
<title>Warmbly</title>
<meta name="apple-mobile-web-app-title" content="Warmbly" />
</head>
<body>
+5
View File
@@ -1,7 +1,12 @@
import RippleProvider from "@/hooks/RippleProvider";
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
import { Outlet } from "react-router-dom";
export default function RootLayout() {
// Keep the browser tab title in sync with the active route across the whole
// app (auth, onboarding, dashboard). See useDocumentTitle for the route map.
useDocumentTitle();
return (
<RippleProvider>
<Outlet />
+100
View File
@@ -0,0 +1,100 @@
import { useEffect } from "react";
import { useLocation } from "react-router-dom";
/*
* Dynamic document titles for the SPA.
*
* react-router is used here in declarative/library mode (createBrowserRouter +
* RouterProvider, no SSR), so the framework-mode `meta` export does not apply.
* Instead we keep one central route -> label map and set `document.title` on
* every navigation. Titles read "Section | Warmbly" (mirrors the marketing
* site's separator); the bare brand is the fallback for unmapped routes.
*
* Called once from RootLayout, which renders the <Outlet/> for every route, so
* a single hook covers auth, onboarding and the whole /app dashboard.
*/
const BRAND = "Warmbly";
// Static routes: exact pathname -> label. Dynamic segments (:id) are handled
// by the parameterised list below.
const ROUTE_TITLES: Record<string, string> = {
"/": BRAND,
// Auth
"/auth/login": "Sign in",
"/auth/login/confirm": "Verify your email",
"/auth/register": "Create your account",
"/auth/register/confirm": "Confirm your email",
"/auth/reset-password": "Reset your password",
"/auth/reset-password/confirm": "Set a new password",
// Onboarding / workspace selection
"/onboarding": "Welcome",
"/select-org": "Select workspace",
// App
"/app/emails": "Mailboxes",
"/app/contacts": "Contacts",
"/app/campaigns": "Campaigns",
"/app/analytics": "Analytics",
"/app/crm/pipelines": "Pipelines",
"/app/crm/deals": "Deals",
"/app/crm/tasks": "Tasks",
"/app/templates": "Templates",
"/app/api-keys": "API keys",
"/app/integrations": "Integrations",
"/app/audit": "Audit log",
"/app/unibox": "Unibox",
// Settings
"/app/settings/profile": "Profile",
"/app/settings/notifications": "Notifications",
"/app/settings/security": "Security",
"/app/settings/members": "Members",
"/app/settings/workspace": "Workspace",
"/app/settings/billing": "Billing",
"/app/settings/limits": "Plan & limits",
"/app/settings/roles": "Roles",
"/app/settings/danger": "Danger zone",
// Admin
"/app/admin": "Admin",
"/app/admin/workers": "Workers",
"/app/admin/workers/new": "Add worker",
"/app/admin/credentials": "Credentials",
"/app/admin/audit": "Admin audit",
};
// Parameterised routes: [regex, label]. Ordered most-specific first so a
// nested path matches its own entry before the shorter parent pattern.
const PARAM_ROUTES: ReadonlyArray<readonly [RegExp, string]> = [
[/^\/app\/campaigns\/[^/]+\/leads$/, "Campaign leads"],
[/^\/app\/campaigns\/[^/]+\/preferences$/, "Campaign settings"],
[/^\/app\/campaigns\/[^/]+\/schedule$/, "Campaign schedule"],
[/^\/app\/campaigns\/[^/]+\/sequences$/, "Campaign sequences"],
[/^\/app\/campaigns\/[^/]+$/, "Campaign"],
[/^\/app\/admin\/workers\/[^/]+$/, "Worker"],
];
function titleForPath(pathname: string): string {
const label = ROUTE_TITLES[pathname];
if (label !== undefined) return label === BRAND ? BRAND : `${label} | ${BRAND}`;
for (const [pattern, paramLabel] of PARAM_ROUTES) {
if (pattern.test(pathname)) return `${paramLabel} | ${BRAND}`;
}
return BRAND;
}
/**
* Sets document.title from the current route. Pass an explicit `override` to
* title a page from loaded data (e.g. a campaign name) instead of the map.
*/
export function useDocumentTitle(override?: string) {
const { pathname } = useLocation();
useEffect(() => {
document.title = override ? `${override} | ${BRAND}` : titleForPath(pathname);
}, [pathname, override]);
}
@@ -4,6 +4,8 @@ interface CompleteOnboardingData {
first_name: string;
last_name: string;
referral_source: string;
role?: string;
team_size?: string;
}
export default async function completeOnboarding(data: CompleteOnboardingData): Promise<void> {
@@ -5,6 +5,8 @@ interface CompleteOnboardingData {
first_name: string;
last_name: string;
referral_source: string;
role?: string;
team_size?: string;
}
export default function useCompleteOnboarding() {