mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-07 00:01:37 +00:00
feat: add role and team-size onboarding questions and rework site SEO me
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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,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 |
@@ -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.
@@ -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>
|
||||
@@ -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)
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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."
|
||||
>
|
||||
<!-- ============================================================
|
||||
|
||||
@@ -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."
|
||||
>
|
||||
<!-- ============================================================
|
||||
|
||||
@@ -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."
|
||||
>
|
||||
<!-- ============================================================
|
||||
|
||||
@@ -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."
|
||||
>
|
||||
<!-- ============================================================
|
||||
|
||||
@@ -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."
|
||||
>
|
||||
<!-- ============================================================
|
||||
|
||||
@@ -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."
|
||||
>
|
||||
<!-- =====================================================
|
||||
|
||||
@@ -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."
|
||||
>
|
||||
<!-- ============================================================
|
||||
|
||||
@@ -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."
|
||||
>
|
||||
<!-- ============================================================
|
||||
|
||||
@@ -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."
|
||||
>
|
||||
<!-- ============================================================
|
||||
|
||||
@@ -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%);">
|
||||
|
||||
@@ -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."
|
||||
>
|
||||
<!-- ============================================================
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."
|
||||
>
|
||||
<!-- ============================================================
|
||||
|
||||
@@ -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,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"
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."
|
||||
>
|
||||
<!-- ============================================================
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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."
|
||||
>
|
||||
<!-- ============================================================
|
||||
|
||||
@@ -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."
|
||||
>
|
||||
<!-- ============================================================
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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."
|
||||
>
|
||||
|
||||
|
||||
@@ -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."
|
||||
>
|
||||
<!-- ============================================================
|
||||
|
||||
@@ -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."
|
||||
>
|
||||
<!-- ============================================================
|
||||
|
||||
@@ -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."
|
||||
>
|
||||
<!-- ============================================================
|
||||
|
||||
@@ -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%);">
|
||||
|
||||
@@ -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."
|
||||
>
|
||||
<!-- ============================================================
|
||||
|
||||
@@ -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."
|
||||
>
|
||||
<!-- ============================================================
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user