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 ( +
+