From 315509d0da8ed8a785ff3c1eac809191d94ab577 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sat, 29 Aug 2026 08:48:13 -0700 Subject: [PATCH] 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) --- internal/notify/templates/base.go | 4 +- web/src/app/app/emails/page.tsx | 6 + .../components/app/emails/CloudPathsPanel.tsx | 108 +++++++++++++ .../components/layout/AccessLockedDialog.tsx | 4 +- .../components/layout/SubscriptionGate.tsx | 33 ++-- .../layout/SubscriptionLockedScreen.tsx | 148 ++++++++++++++++++ web/src/lib/plans.ts | 4 +- 7 files changed, 288 insertions(+), 19 deletions(-) create mode 100644 web/src/components/app/emails/CloudPathsPanel.tsx create mode 100644 web/src/components/layout/SubscriptionLockedScreen.tsx diff --git a/internal/notify/templates/base.go b/internal/notify/templates/base.go index f4af3f1f..03b91293 100644 --- a/internal/notify/templates/base.go +++ b/internal/notify/templates/base.go @@ -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") diff --git a/web/src/app/app/emails/page.tsx b/web/src/app/app/emails/page.tsx index a47a9ea1..22b7ad58 100644 --- a/web/src/app/app/emails/page.tsx +++ b/web/src/app/app/emails/page.tsx @@ -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" /> setCloudDialog(true)} mailboxCount={stats.total} /> + {!emailsData.isLoading && p?.setAddEmail(true)} />} ) : !emailsData.emails || emailsData.emails.length === 0 ? ( + cloud.selfHosted || authConfigLoading ? ( } /> + ) : null ) : ( diff --git a/web/src/components/app/emails/CloudPathsPanel.tsx b/web/src/components/app/emails/CloudPathsPanel.tsx new file mode 100644 index 00000000..9e4ef43f --- /dev/null +++ b/web/src/components/app/emails/CloudPathsPanel.tsx @@ -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 ( +
+ +
+ + {limit === null ? "Unlimited mailboxes" : `${FREE_MAILBOXES} mailboxes free`} + +

Start warming your mailboxes

+

+ Two ways in. Both are free, both warm in the same pool of real mailboxes. +

+
+
+
+ + + +

Connect a mailbox here

+

+ Gmail, Microsoft 365 or any SMTP/IMAP account. Warmup starts on its own, replies and spam rescue included. +

+ +
+
+ + + +

Self-host for free, link your instance

+

+ Run Warmbly on your own server with unlimited mailboxes and campaigns, then connect it from its Settings so this workspace warms them. +

+
+ + Self-host guide + + + I have a code + +
+
+
+
+
+ ); + } + + return ( +
+
+ + + + {limit === null ? `${used} mailboxes warming in the pool.` : `${used} of ${limit} free mailboxes used.`} + {" "} + + {linked > 0 ? `${linked} linked instance${linked === 1 ? "" : "s"}. ` : ""} + Self-host for free and link the instance to warm more. + + + + Self-host + + + Linked instances + +
+
+ ); +} diff --git a/web/src/components/layout/AccessLockedDialog.tsx b/web/src/components/layout/AccessLockedDialog.tsx index e7339306..7707822a 100644 --- a/web/src/components/layout/AccessLockedDialog.tsx +++ b/web/src/components/layout/AccessLockedDialog.tsx @@ -69,7 +69,7 @@ export default function AccessLockedDialog({
@@ -93,7 +93,7 @@ export default function AccessLockedDialog({ Upgrade diff --git a/web/src/components/layout/SubscriptionGate.tsx b/web/src/components/layout/SubscriptionGate.tsx index bf753925..7bf74b7d 100644 --- a/web/src/components/layout/SubscriptionGate.tsx +++ b/web/src/components/layout/SubscriptionGate.tsx @@ -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 ( - - {children} - - ); + if (!access.locked || open) return <>{children}; + const feature = FEATURE_BY_PREFIX.find(([p]) => pathname === p || pathname.startsWith(p + "/"))?.[1] ?? "This page"; + return ; } diff --git a/web/src/components/layout/SubscriptionLockedScreen.tsx b/web/src/components/layout/SubscriptionLockedScreen.tsx new file mode 100644 index 00000000..42021a1a --- /dev/null +++ b/web/src/components/layout/SubscriptionLockedScreen.tsx @@ -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 ( +
+