feat: replace the purple lock card with a full-screen plan chooser on the auth screen's sky (warm 10 mailboxes free, self-host and link, or choose a plan), give the hosted Accounts page a two-path panel (connect a mailbox here, or self-host for free and link the instance) that collapses to a usage strip once mailboxes exist, drop indigo from the access dialog, describe the free plan honestly in the catalog, and default the transactional email footer to the real company details (Mindroot Ltd, 16543299, 71-75 Shelton Street)

This commit is contained in:
Matthew Meszaros
2026-08-29 08:48:13 -07:00
parent d621d9b79b
commit 315509d0da
7 changed files with 288 additions and 19 deletions
+2 -2
View File
@@ -17,9 +17,9 @@ import (
var (
CompanyName = brandEnv("EMAIL_BRAND_NAME", "Warmbly")
LegalEntity = brandEnv("EMAIL_BRAND_LEGAL_ENTITY", "Mindroot Ltd")
CompanyNumber = brandEnv("EMAIL_BRAND_COMPANY_NUMBER", "00000000")
CompanyNumber = brandEnv("EMAIL_BRAND_COMPANY_NUMBER", "16543299")
PlaceOfReg = brandEnv("EMAIL_BRAND_PLACE_OF_REG", "England and Wales")
RegisteredAddr = brandEnv("EMAIL_BRAND_ADDRESS", "1 Example Street, London, W1A 1AA")
RegisteredAddr = brandEnv("EMAIL_BRAND_ADDRESS", "71-75 Shelton Street, London, England, WC2H 9JQ")
WebsiteURL = brandEnv("EMAIL_BRAND_WEBSITE_URL", "https://warmbly.com")
AppURL = appURL()
SupportEmail = brandEnv("EMAIL_BRAND_SUPPORT_EMAIL", "team@warmbly.com")
+6
View File
@@ -16,8 +16,10 @@ import { useConfirm } from "@/hooks/context/confirm";
import InboxDetails from "@/components/app/emails/InboxDetails";
import WarmupCoverageNotice from "@/components/app/emails/WarmupCoverageNotice";
import CloudPoolBanner from "@/components/app/emails/CloudPoolBanner";
import CloudPathsPanel from "@/components/app/emails/CloudPathsPanel";
import CloudConnectDialog from "@/components/app/cloud/CloudConnectDialog";
import useCloudPool from "@/hooks/useCloudPool";
import useAuthConfig from "@/lib/api/hooks/auth/useAuthConfig";
import { useEnrollCloudLinkMailbox, useUnenrollCloudLinkMailbox, useCloudLinkMailboxLifecycle } from "@/lib/api/hooks/app/cloudlink/useCloudLink";
import { providerSupported } from "@/app/app/settings/warmbly-cloud/providers";
import { CloudIcon } from "lucide-react";
@@ -115,6 +117,7 @@ export default function AddressesPage() {
// row badges and menu items below key off this.
const cloud = useCloudPool();
const [cloudDialog, setCloudDialog] = React.useState(false);
const authConfigLoading = useAuthConfig().isLoading;
// One query for the whole surface; each row reads its own advice out of the
// index rather than asking for it.
@@ -327,6 +330,7 @@ export default function AddressesPage() {
className="mx-5 my-3"
/>
<CloudPoolBanner onConnect={() => setCloudDialog(true)} mailboxCount={stats.total} />
{!emailsData.isLoading && <CloudPathsPanel mailboxCount={stats.total} onAdd={() => p?.setAddEmail(true)} />}
<WarmupCoverageNotice
warmupCount={warmupActive}
totalCount={stats.total}
@@ -348,6 +352,7 @@ export default function AddressesPage() {
))}
</div>
) : !emailsData.emails || emailsData.emails.length === 0 ? (
cloud.selfHosted || authConfigLoading ? (
<EmptyBlock
title="No email accounts yet"
body="Connect your first mailbox to start warming up and sending campaigns."
@@ -360,6 +365,7 @@ export default function AddressesPage() {
</TopbarAction>
}
/>
) : null
) : (
<table className="w-full text-left">
<thead className="sticky top-0 bg-white z-[1]">
@@ -0,0 +1,108 @@
// Hosted free workspace, Accounts page: the two ways to get mailboxes
// warming (connect them here, or self-host and link the instance). Full
// panel when the list is empty, one strip once mailboxes exist.
import React from "react";
import { Link } from "react-router-dom";
import { motion } from "framer-motion";
import { ArrowRightIcon, CloudIcon, InboxIcon, PlusIcon, ServerIcon } from "lucide-react";
import useFeatureAccess from "@/hooks/useFeatureAccess";
import useAuthConfig from "@/lib/api/hooks/auth/useAuthConfig";
import { usePoolLinkInstances } from "@/lib/api/hooks/app/cloudlink/useCloudLink";
const SELF_HOST_DOCS = "https://docs.warmbly.com/development/deployment-guide/";
const FREE_MAILBOXES = 10;
export default function CloudPathsPanel({ mailboxCount, onAdd }: { mailboxCount: number; onAdd: () => void }) {
const authConfig = useAuthConfig();
const access = useFeatureAccess();
const hosted = authConfig.data?.self_hosted === false;
const instances = usePoolLinkInstances(hosted);
const linked = instances.data?.data.length ?? 0;
const plan = instances.data?.plan;
if (!hosted || access.loading) return null;
const limit = plan?.mailbox_limit ?? (access.paid ? null : FREE_MAILBOXES);
const used = plan?.enrolled ?? mailboxCount;
if (mailboxCount === 0 && linked === 0) {
return (
<div className="px-5 py-6">
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className="rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-6 text-center border-b border-slate-200/70 bg-gradient-to-b from-sky-50/70 to-white">
<span className="inline-flex items-center gap-1.5 h-6 px-2.5 rounded-full bg-white border border-sky-100 text-sky-700 text-[11px] font-medium">
<CloudIcon className="w-3 h-3" /> {limit === null ? "Unlimited mailboxes" : `${FREE_MAILBOXES} mailboxes free`}
</span>
<h2 className="mt-3 text-[20px] font-semibold tracking-[-0.02em] text-slate-900">Start warming your mailboxes</h2>
<p className="mt-1.5 text-[13px] text-slate-500 max-w-md mx-auto leading-relaxed">
Two ways in. Both are free, both warm in the same pool of real mailboxes.
</p>
</div>
<div className="grid md:grid-cols-2 divide-y md:divide-y-0 md:divide-x divide-slate-200/70">
<div className="px-6 py-5">
<span className="size-8 rounded-lg bg-sky-600 text-white inline-flex items-center justify-center">
<InboxIcon className="w-4 h-4" />
</span>
<h3 className="mt-3 text-[14px] font-semibold text-slate-900">Connect a mailbox here</h3>
<p className="mt-1 text-[12.5px] text-slate-500 leading-relaxed">
Gmail, Microsoft 365 or any SMTP/IMAP account. Warmup starts on its own, replies and spam rescue included.
</p>
<button
type="button"
onClick={onAdd}
className="mt-4 inline-flex items-center gap-1.5 h-8 px-3 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[12.5px] font-medium transition-colors"
>
<PlusIcon className="w-3.5 h-3.5" /> Add account
</button>
</div>
<div className="px-6 py-5">
<span className="size-8 rounded-lg bg-slate-900 text-white inline-flex items-center justify-center">
<ServerIcon className="w-4 h-4" />
</span>
<h3 className="mt-3 text-[14px] font-semibold text-slate-900">Self-host for free, link your instance</h3>
<p className="mt-1 text-[12.5px] text-slate-500 leading-relaxed">
Run Warmbly on your own server with unlimited mailboxes and campaigns, then connect it from its Settings so this workspace warms them.
</p>
<div className="mt-4 flex flex-wrap items-center gap-2">
<a
href={SELF_HOST_DOCS}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-md border border-slate-200 hover:border-slate-300 text-[12.5px] font-medium text-slate-800 transition-colors"
>
Self-host guide <ArrowRightIcon className="w-3.5 h-3.5" />
</a>
<Link to="/connect" className="text-[12.5px] font-medium text-sky-700 hover:text-sky-900">
I have a code
</Link>
</div>
</div>
</div>
</motion.div>
</div>
);
}
return (
<div className="px-5 pt-4">
<div className="flex items-center gap-2.5 rounded-md border border-slate-200 bg-white px-3 py-2 text-[12.5px] text-slate-700">
<CloudIcon className="w-4 h-4 shrink-0 text-sky-600" />
<span className="min-w-0 flex-1 leading-snug">
<span className="font-medium">
{limit === null ? `${used} mailboxes warming in the pool.` : `${used} of ${limit} free mailboxes used.`}
</span>{" "}
<span className="text-slate-500">
{linked > 0 ? `${linked} linked instance${linked === 1 ? "" : "s"}. ` : ""}
Self-host for free and link the instance to warm more.
</span>
</span>
<a href={SELF_HOST_DOCS} target="_blank" rel="noreferrer" className="shrink-0 text-[12px] font-medium text-slate-600 hover:text-slate-900 underline underline-offset-2">
Self-host
</a>
<Link to="/app/settings/warmbly-cloud" className="shrink-0 text-[12px] font-medium text-sky-700 hover:text-sky-900 underline underline-offset-2">
Linked instances
</Link>
</div>
</div>
);
}
@@ -69,7 +69,7 @@ export default function AccessLockedDialog({
<div
className={
isPlan
? "mx-auto mb-3 size-11 rounded-xl bg-indigo-50 border border-indigo-200 text-indigo-600 flex items-center justify-center"
? "mx-auto mb-3 size-11 rounded-xl bg-sky-50 border border-sky-200 text-sky-600 flex items-center justify-center"
: "mx-auto mb-3 size-11 rounded-xl bg-amber-50 border border-amber-200 text-amber-600 flex items-center justify-center"
}
>
@@ -93,7 +93,7 @@ export default function AccessLockedDialog({
<Link
to={upgradeTo}
onClick={onClose}
className="inline-flex items-center gap-1.5 h-8 px-4 rounded-md bg-indigo-600 hover:bg-indigo-700 text-white text-[12.5px] font-medium transition-colors"
className="inline-flex items-center gap-1.5 h-8 px-4 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[12.5px] font-medium transition-colors"
>
<SparklesIcon className="w-3.5 h-3.5" /> Upgrade
</Link>
+20 -13
View File
@@ -1,26 +1,33 @@
// A hosted workspace without a subscription can manage mailboxes, its
// Warmbly Cloud links and settings; every other page renders behind the
// upgrade overlay until a plan is active.
// Warmbly Cloud links and settings; every other page shows the full-screen
// plan chooser until a plan is active.
import React from "react";
import { useLocation } from "react-router-dom";
import useFeatureAccess from "@/hooks/useFeatureAccess";
import { LockedSurface } from "./LockedSurface";
import SubscriptionLockedScreen from "./SubscriptionLockedScreen";
const OPEN_PREFIXES = ["/app/emails", "/app/settings", "/app/select-org"];
const FEATURE_BY_PREFIX: [string, string][] = [
["/app/unibox", "The unified inbox"],
["/app/campaigns", "Campaigns"],
["/app/contacts", "Contacts"],
["/app/analytics", "Analytics"],
["/app/deliverability", "Deliverability"],
["/app/crm", "The CRM"],
["/app/templates", "Templates"],
["/app/integrations", "Integrations"],
["/app/automations", "Automations"],
["/app/api-keys", "API keys"],
["/app/audit", "The audit log"],
];
export default function SubscriptionGate({ children }: { children: React.ReactNode }) {
const access = useFeatureAccess();
const { pathname } = useLocation();
const open = OPEN_PREFIXES.some((p) => pathname === p || pathname.startsWith(p + "/"));
return (
<LockedSurface
locked={access.locked && !open}
feature="Sending, inbox and CRM"
blurb="Your workspace is on the free plan: connect up to 10 mailboxes and warm them, or link a self-hosted instance. Campaigns, the unified inbox, contacts, CRM and integrations unlock with a plan."
minPlan="starter"
>
{children}
</LockedSurface>
);
if (!access.locked || open) return <>{children}</>;
const feature = FEATURE_BY_PREFIX.find(([p]) => pathname === p || pathname.startsWith(p + "/"))?.[1] ?? "This page";
return <SubscriptionLockedScreen feature={feature} />;
}
@@ -0,0 +1,148 @@
// Full-page screen a hosted workspace sees on a locked page before it has a
// plan: the auth screen's sky, one floating card, and the three ways forward.
import React from "react";
import { Link } from "react-router-dom";
import { motion } from "framer-motion";
import { ArrowRightIcon, CheckIcon, CloudIcon, InboxIcon, ServerIcon, SparklesIcon } from "lucide-react";
import { getPlan } from "@/lib/plans";
import { useAppStore } from "@/stores";
const SELF_HOST_DOCS = "https://docs.warmbly.com/development/deployment-guide/";
export default function SubscriptionLockedScreen({ feature }: { feature: string }) {
const isOwner = useAppStore((s) => s.currentOrganization?.role === "owner");
const starter = getPlan("starter");
return (
<div className="relative min-h-full w-full overflow-hidden flex items-center justify-center px-4 py-10">
<div className="absolute inset-0" aria-hidden="true">
<div className="sky-base" />
<div className="sky-breathe" />
<div className="sun-glow" />
<img src="/backdrops/cloud-3.webp" alt="" decoding="async" className="cloud-drift cloud-1 absolute select-none" style={{ top: "6%", left: "-10%", width: 360, opacity: 0.55, height: "auto" }} />
<img src="/backdrops/cloud-4.webp" alt="" decoding="async" className="cloud-drift cloud-2 absolute select-none" style={{ bottom: "8%", right: "-8%", width: 320, opacity: 0.5, height: "auto" }} />
<img src="/backdrops/cloud-1.webp" alt="" decoding="async" className="cloud-drift cloud-1 absolute select-none" style={{ top: "40%", right: "12%", width: 220, opacity: 0.35, height: "auto" }} />
</div>
<motion.div
initial={{ y: 14, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
className="relative z-10 w-full max-w-[920px] animate-card-float rounded-3xl border border-slate-200 bg-white shadow-[0_1px_2px_rgba(15,23,42,0.04),0_30px_70px_-32px_rgba(15,23,42,0.32)] overflow-hidden"
>
<div className="px-7 pt-8 pb-6 md:px-10 md:pt-10 text-center">
<span className="inline-flex items-center gap-1.5 h-6 px-2.5 rounded-full bg-sky-50 text-sky-700 text-[11px] font-medium">
<SparklesIcon className="w-3 h-3" /> Free workspace
</span>
<h1 className="mt-4 text-[26px] md:text-[34px] font-semibold tracking-[-0.03em] leading-[1.08] text-slate-900">
{feature} unlocks with a plan.
</h1>
<p className="mt-3 text-[14px] text-slate-500 leading-relaxed max-w-xl mx-auto">
Your workspace is free forever for warming mailboxes. Pick how you want to send: connect mailboxes here, run Warmbly on your own server,
or choose a plan for the full hosted product.
</p>
</div>
<div className="grid md:grid-cols-3 gap-px bg-slate-200/70 border-y border-slate-200/70">
<Path
icon={InboxIcon}
eyebrow="Included"
title="Warm up to 10 mailboxes"
body="Connect mailboxes and they warm in the Warmbly pool at no cost, with replies and spam rescue handled for you."
cta="Go to mailboxes"
to="/app/emails"
/>
<Path
icon={ServerIcon}
eyebrow="Free"
title="Self-host Warmbly"
body="Run the whole platform on your server, unlimited, then link the instance so this workspace warms its mailboxes."
cta="Self-host guide"
href={SELF_HOST_DOCS}
secondary={{ label: "Linked instances", to: "/app/settings/warmbly-cloud" }}
/>
<Path
icon={CloudIcon}
eyebrow={starter.priceMonthly != null ? `From $${starter.priceMonthly}/mo` : "Plans"}
title="Send from Warmbly Cloud"
body="Campaigns, the unified inbox, contacts, CRM, automations and integrations on our infrastructure."
bullets={starter.bullets}
cta={isOwner ? "Choose a plan" : "See plans"}
to="/app/settings/billing"
primary
/>
</div>
<div className="px-7 py-4 md:px-10 flex flex-wrap items-center justify-between gap-2 text-[12px] text-slate-500">
<span>Settings, billing and your mailboxes stay open on the free workspace.</span>
{!isOwner && <span>Only the workspace owner can choose a plan.</span>}
</div>
</motion.div>
</div>
);
}
function Path({
icon: Icon,
eyebrow,
title,
body,
bullets,
cta,
to,
href,
secondary,
primary = false,
}: {
icon: React.ComponentType<{ className?: string }>;
eyebrow: string;
title: string;
body: string;
bullets?: string[];
cta: string;
to?: string;
href?: string;
secondary?: { label: string; to: string };
primary?: boolean;
}) {
const btn = primary
? "inline-flex items-center gap-1.5 h-9 px-3.5 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[13px] font-medium transition-colors"
: "inline-flex items-center gap-1.5 h-9 px-3.5 rounded-md border border-slate-200 hover:border-slate-300 bg-white text-[13px] font-medium text-slate-800 transition-colors";
return (
<div className={`bg-white px-6 py-6 flex flex-col ${primary ? "bg-gradient-to-b from-sky-50/60 to-white" : ""}`}>
<span className={`size-9 rounded-lg inline-flex items-center justify-center ${primary ? "bg-sky-600 text-white" : "bg-slate-100 text-slate-700"}`}>
<Icon className="w-4 h-4" />
</span>
<span className="mt-4 text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">{eyebrow}</span>
<h2 className="mt-1 text-[15px] font-semibold text-slate-900">{title}</h2>
<p className="mt-1.5 text-[12.5px] text-slate-500 leading-relaxed">{body}</p>
{bullets && (
<ul className="mt-3 space-y-1">
{bullets.map((b) => (
<li key={b} className="flex items-start gap-1.5 text-[12px] text-slate-700">
<CheckIcon className="w-3 h-3 text-emerald-600 mt-0.5 shrink-0" />
{b}
</li>
))}
</ul>
)}
<div className="mt-auto pt-5 flex flex-wrap items-center gap-2">
{href ? (
<a href={href} target="_blank" rel="noreferrer" className={btn}>
{cta} <ArrowRightIcon className="w-3.5 h-3.5" />
</a>
) : (
<Link to={to ?? "/app"} className={btn}>
{cta} <ArrowRightIcon className="w-3.5 h-3.5" />
</Link>
)}
{secondary && (
<Link to={secondary.to} className="text-[12px] font-medium text-slate-500 hover:text-slate-900">
{secondary.label}
</Link>
)}
</div>
</div>
);
}
+2 -2
View File
@@ -33,11 +33,11 @@ export const PLAN_CATALOG: Record<PlanID, PlanDef> = {
free: {
id: "free",
label: "Free",
description: "No active subscription — try one out.",
description: "Warm up to 10 mailboxes, link self-hosted instances.",
priceMonthly: 0,
priceAnnual: 0,
sendsPerDay: 0,
bullets: ["Limited preview", "No active sending"],
bullets: ["Up to 10 mailboxes with warmup", "Link self-hosted instances", "No sending"],
accent: "slate",
dedicatedIps: false,
},