diff --git a/docs/content/docs/guides/billing.mdx b/docs/content/docs/guides/billing.mdx index 4c803c9f..8c76c04d 100644 --- a/docs/content/docs/guides/billing.mdx +++ b/docs/content/docs/guides/billing.mdx @@ -11,7 +11,7 @@ Every hosted workspace starts free. The free workspace warms up to 10 mailboxes | --- | --- | --- | --- | --- | | Starter | $29 | $23 | 150 | Unlimited warmup, unlimited mailboxes, unified inbox, team invitations, bulk contact operations | | Grow | $89 | $71 | 3,000 | Everything in Starter | -| Business | $329 | $263 | 15,000 | Everything in Grow, dedicated IPs, webhooks, advanced outreach | +| Business | $329 | $263 | 15,000 | Everything in Grow, sending kept apart from other customers, webhooks, advanced outreach | | Enterprise | Custom | Custom | 15,000+ | Everything in Business, dedicated support | Annual billing is 20% off the monthly price. The same lineup is on the [pricing page](https://warmbly.com/pricing). @@ -37,12 +37,11 @@ Only the workspace owner can change the plan. Other members see the same compari - The plan card shows the plan, its status, the price, and the renewal or end date. Change plan opens the same chooser the locked features use. - Cancel plan schedules the cancellation for the end of the paid period. The plan stays fully active until then, and Resume clears it with one click. Nothing is lost either way: the workspace returns to the free tier and keeps its mailboxes, warmup and settings. - Usage and limits shows live counts against the limits the server actually enforces, for mailboxes, contacts, campaigns, team members, sends this period, and API calls. A meter turns amber past 70% and red past 90%. A meter with no cap means that limit is unmetered on your plan. -- What is included lists every capability the plan turns on or leaves locked. A locked row opens the plan chooser. - Banners appear only when something needs a decision: a failed payment, a scheduled cancellation, or a trial about to end. Need a single limit raised without moving plan? Request an increase from **Settings > Limits**, linked from the usage section. An operator reviews it and the approved limit takes effect on the workspace. -**Plans** compares the lineup as full plan cards, with the next step up from your current plan highlighted. Below them, a side by side table puts every limit and capability in one grid, with your current plan column highlighted so you can see exactly what would change. Limits shown are the ones this instance enforces. On a paid plan each card also prices the switch before you commit, showing what is due today and when the next bill lands. Promo codes and the codes this workspace has redeemed live here too. +**Plans** compares the lineup as full plan cards, with the best value plan highlighted. On a paid plan each card also prices the switch before you commit, showing what is due today and when the next bill lands. Promo codes and the codes this workspace has redeemed live here too. **AI and credits** covers the credit balance, top-ups and usage. See [AI credits](/guides/ai-credits/). diff --git a/site/src/pages/faq.astro b/site/src/pages/faq.astro index 0ecba6da..fb26dfb8 100644 --- a/site/src/pages/faq.astro +++ b/site/src/pages/faq.astro @@ -22,7 +22,7 @@ const groups = [ items: [ ['What is the per-mailbox cap?', 'Default 50 cold emails / day with a 10-minute minimum gap. You can lower it. You can raise it, but anything above 50 / day requires positive reputation signals.'], ['How do you spread sending across IPs?', 'One worker per machine, each with its own IP. We distribute mailboxes across workers and assign campaign sends round-robin across the chosen sender pool.'], - ['Do you offer dedicated IPs?', 'Yes, on Business. A dedicated worker comes with its own IP and is allocated to a single organisation.'], + ['Do you offer dedicated IPs?', 'Business and Enterprise run your sending on a worker machine allocated to your organisation alone, so your reputation is not shared with other customers. We do not sell or manage IPs as a product, and we do not let you bring your own.'], ['What providers do you support?', 'Google Workspace / Gmail, Microsoft 365 / Outlook, and any SMTP / IMAP provider. iCloud and Zoho are first-class too.'], ], }, diff --git a/site/src/pages/pricing.astro b/site/src/pages/pricing.astro index 5b99facf..80a55f55 100644 --- a/site/src/pages/pricing.astro +++ b/site/src/pages/pricing.astro @@ -53,7 +53,9 @@ const plans = [ features: [ 'Everything in Grow', '15,000 emails / day', - 'Dedicated IPs', + // Not an IP product: the mechanism is a worker machine bound to one org, + // and what it buys is reputation isolation. index.astro words it the same. + 'Sending kept apart from other customers', 'Team roles + audit log', 'Priority support', ], diff --git a/site/src/pages/use-cases/agencies.astro b/site/src/pages/use-cases/agencies.astro index 081902fd..207eb4c7 100644 --- a/site/src/pages/use-cases/agencies.astro +++ b/site/src/pages/use-cases/agencies.astro @@ -77,7 +77,7 @@ const faq = [ 'As many as you subscribe to. Each client is its own workspace with its own subscription, and workspaces stack, so capacity scales with the roster instead of with a seat count.'], ['Do I have to manage billing for every client?', 'You own every workspace subscription as the agency. Clients do not see a Warmbly invoice unless you transfer ownership. Mark up whatever you want at the agency level.'], - ['Can a client get its own dedicated IPs?', + ['Can a client get its own sending infrastructure?', 'Yes. By default every paid workspace shares the premium warmup and worker pool. For a client that needs its own reputation surface, you can move it to dedicated workers, each its own machine and IP, carrying only that client\'s mail.'], ['What happens when a client\'s mailbox starts landing in spam?', 'It steps down the health ladder (Healthy, Watch, Quarantined, Blocked) and is pulled from the shared pool well before mailbox providers penalize it, acting around 10% spam placement rather than 80%, then recovers inside its own workspace.'], @@ -262,7 +262,7 @@ const faq = [
Sending infrastructure

- Shared premium pool, or dedicated IPs per client. + Shared premium pool, or sending kept apart per client.

Every paid workspace warms and sends on the shared premium pool. When a client grows big enough to want its own IPs, move it onto dedicated workers, each on its own machine, sending only that client's mail. diff --git a/web/src/app/app/settings/billing/OverviewTab.tsx b/web/src/app/app/settings/billing/OverviewTab.tsx index acf0f5ae..50b24b06 100644 --- a/web/src/app/app/settings/billing/OverviewTab.tsx +++ b/web/src/app/app/settings/billing/OverviewTab.tsx @@ -18,7 +18,6 @@ import { FileTextIcon, InfoIcon, Loader2Icon, - LockIcon, PlayIcon, SlidersHorizontalIcon, SparklesIcon, @@ -30,21 +29,19 @@ import { useConfirm } from "@/hooks/context/confirm"; import useSubscription from "@/lib/api/hooks/app/subscription/useSubscription"; import useSubscriptionLimits from "@/lib/api/hooks/app/subscription/useSubscriptionLimits"; import useTrialStatus from "@/lib/api/hooks/app/subscription/useTrialStatus"; -import useFeatureStatus from "@/lib/api/hooks/app/subscription/useFeatureStatus"; import useCancelSubscription from "@/lib/api/hooks/app/subscription/useCancelSubscription"; import useOrganizationLimits from "@/lib/api/hooks/app/organizations/useOrganizationLimits"; import useUsageOverview from "@/lib/api/hooks/app/analytics/useUsageOverview"; import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; import { AnimatedNumber, DitherMeter, type DitherTone } from "@/components/ui/dither"; -import { PLAN_ACCENT_CLASSES, getPlan, type PlanID } from "@/lib/plans"; +import { PLAN_ACCENT_CLASSES, getPlan } from "@/lib/plans"; import { Section } from "../_components/SectionShell"; export default function OverviewTab({ onChangePlan }: { onChangePlan: () => void }) { const access = useFeatureAccess(); const sub = useSubscription(); const trial = useTrialStatus(); - const features = useFeatureStatus(); const subLimits = useSubscriptionLimits(); const orgLimits = useOrganizationLimits(); const usage = useUsageOverview().data; @@ -307,74 +304,6 @@ export default function OverviewTab({ onChangePlan }: { onChangePlan: () => void

- {/* ── Capability matrix ────────────────────────────────────── */} -
- {features.isPending ? ( -
- ) : ( -
- - - - - - - - -
- )} -
); } @@ -484,53 +413,6 @@ function UsageMeter({ ); } -function FeatureRow({ - label, - hint, - on, - minPlan, - onUpgrade, -}: { - label: string; - hint: string; - on: boolean; - minPlan: PlanID; - onUpgrade: () => void; -}) { - const plan = getPlan(minPlan); - const accent = PLAN_ACCENT_CLASSES[plan.accent]; - return ( -
- - {on ? : } - -
-
- {label} -
-
{hint}
-
- {on ? ( - - On - - ) : ( - - )} -
- ); -} - function StatusPill({ status, cancelAtEnd, diff --git a/web/src/app/app/settings/billing/page.tsx b/web/src/app/app/settings/billing/page.tsx index 45606447..d3cf7780 100644 --- a/web/src/app/app/settings/billing/page.tsx +++ b/web/src/app/app/settings/billing/page.tsx @@ -41,9 +41,8 @@ import buildError from "@/lib/helper/buildError"; import { TextInput } from "@/components/ui/field"; import BillingIntervalToggle from "@/components/app/billing/BillingIntervalToggle"; import PlanCard from "@/components/app/billing/PlanCard"; -import PlanComparison from "@/components/app/billing/PlanComparison"; import { Row, Section, SectionShell, TableSurface } from "../_components/SectionShell"; -import { PAID_PLANS, getPlan, planOrder, type PlanID } from "@/lib/plans"; +import { PAID_PLANS, getPlan, type PlanID } from "@/lib/plans"; import { describeDiscount, fmtMoney, type BillingInterval } from "@/lib/pricing"; import OverviewTab from "./OverviewTab"; import CreditsCard from "./CreditsCard"; @@ -149,9 +148,10 @@ export default function BillingSettingsPage() { setCodeInput(""); } - // Spotlight the next step up from the current plan. + // Spotlight the best-value plan (the one marketed as most popular), not + // whatever happens to sit one rung above the current plan. const recommendedPlan: PlanID = - PAID_PLANS.find((id) => planOrder(id) > planOrder(currentPlan.id)) ?? "business"; + PAID_PLANS.find((id) => getPlan(id).featured) ?? "business"; // Overview's "Change plan" opens the same full-screen chooser the locked // surfaces use, so there is one upgrade experience everywhere. @@ -270,17 +270,6 @@ export default function BillingSettingsPage() { -
- -
-
- {feature ? `Unlocks ${feature}` : "Recommended"} + {feature ? `Unlocks ${feature}` : "Best value"} )} @@ -163,8 +163,9 @@ export default function PlanCard({ Current - ) : plan.featured ? ( - + ) : plan.featured && !recommended ? ( + // The ribbon already says it; two badges on one card reads as noise. + Popular ) : null} diff --git a/web/src/components/app/billing/PlanComparison.tsx b/web/src/components/app/billing/PlanComparison.tsx deleted file mode 100644 index fccb47c0..00000000 --- a/web/src/components/app/billing/PlanComparison.tsx +++ /dev/null @@ -1,193 +0,0 @@ -// Side-by-side plan comparison for Billing > Plans. -// -// Numbers come from the server plan records where the backend has them -// configured (that is what actually gets enforced), and fall back to the -// marketing catalog when a plan is not wired up. The workspace's current plan -// column is highlighted so "what would change" is readable at a glance. - -import React from "react"; -import { CheckIcon, MinusIcon } from "lucide-react"; -import type ServerPlan from "@/lib/api/models/app/subscription/Plan"; -import { - PAID_PLANS, - PLAN_ACCENT_CLASSES, - getPlan, - isAtLeast, - type PlanID, -} from "@/lib/plans"; -import { cn } from "@/lib/utils"; -import type { BillingInterval } from "@/lib/pricing"; - -type Cell = React.ReactNode; - -interface CompareRow { - label: string; - hint?: string; - /** Starts a new labelled band in the table. */ - group?: string; - cell: (id: PlanID, server: ServerPlan | undefined, interval: BillingInterval) => Cell; -} - -const yes = ; -const no = ; - -function num(v: number | null | undefined, fallback: number): string { - const n = v == null || v <= 0 ? fallback : v; - if (!Number.isFinite(n)) return "Custom"; - return n.toLocaleString(); -} - -const ROWS: CompareRow[] = [ - { - group: "Price", - label: "Per month", - hint: "Billed at the interval selected above", - cell: (id, _s, interval) => { - const p = getPlan(id); - const v = interval === "annual" ? p.priceAnnual : p.priceMonthly; - return v == null ? "Custom" : `$${v}`; - }, - }, - { - label: "Per year", - cell: (id, _s, interval) => { - const p = getPlan(id); - const v = interval === "annual" ? p.priceAnnual : p.priceMonthly; - return v == null ? "Custom" : `$${(v * 12).toLocaleString()}`; - }, - }, - { - group: "Limits", - label: "Emails per day", - hint: "Campaign sends across the workspace", - cell: (id, s) => num(s?.limits?.max_emails_per_day, getPlan(id).sendsPerDay), - }, - { - label: "Mailboxes", - hint: "Connected sending and warmup mailboxes", - cell: (_id, s) => (s?.limits?.max_email_accounts ? num(s.limits.max_email_accounts, 0) : "Unlimited"), - }, - { - label: "Contacts", - cell: (_id, s) => (s?.limits?.max_contacts ? num(s.limits.max_contacts, 0) : "Unlimited"), - }, - { - label: "Campaigns", - cell: (_id, s) => (s?.limits?.max_campaigns ? num(s.limits.max_campaigns, 0) : "Unlimited"), - }, - { - label: "Team members", - cell: (_id, s) => (s?.limits?.max_team_members ? num(s.limits.max_team_members, 0) : "Unlimited"), - }, - { - group: "Capabilities", - label: "Mailbox warmup", - hint: "Unlimited on every paid plan", - cell: () => yes, - }, - { label: "Campaign sending", cell: () => yes }, - { label: "Unified inbox", cell: () => yes }, - { label: "Contacts and CRM", cell: () => yes }, - { label: "Automations and integrations", cell: () => yes }, - { label: "Team members and roles", cell: (id) => (isAtLeast(id, "starter") ? yes : no) }, - { label: "Bulk contact operations", cell: (id) => (isAtLeast(id, "starter") ? yes : no) }, - { label: "Webhooks", cell: (id) => (isAtLeast(id, "business") ? yes : no) }, - { - label: "Advanced outreach", - hint: "A/B tests and custom sending rules", - cell: (id) => (isAtLeast(id, "business") ? yes : no), - }, - { label: "Dedicated IPs", cell: (id) => (getPlan(id).dedicatedIps ? yes : no) }, - { label: "Dedicated support", cell: (id) => (id === "enterprise" ? yes : no) }, -]; - -export default function PlanComparison({ - current, - interval, - resolveServerPlan, -}: { - current: PlanID; - interval: BillingInterval; - /** Maps a catalog plan to the server record so real limits win. */ - resolveServerPlan: (id: PlanID) => ServerPlan | undefined; -}) { - return ( -
- - - - - {PAID_PLANS.map((id) => { - const plan = getPlan(id); - const accent = PLAN_ACCENT_CLASSES[plan.accent]; - const isCurrent = id === current; - return ( - - ); - })} - - - - {ROWS.map((row) => ( - - {row.group && ( - - - - )} - - - {PAID_PLANS.map((id) => ( - - ))} - - - ))} - -
- Feature - - - - - {plan.label} - - - {isCurrent && ( - - Current - - )} -
- {row.group} -
- {row.label} - {row.hint && ( - - {row.hint} - - )} - - {row.cell(id, resolveServerPlan(id), interval)} -
-
- ); -} diff --git a/web/src/components/layout/AppNav.tsx b/web/src/components/layout/AppNav.tsx index 7c6b6135..f7334e50 100644 --- a/web/src/components/layout/AppNav.tsx +++ b/web/src/components/layout/AppNav.tsx @@ -112,7 +112,7 @@ interface NavItem { // upgrade dialog all agree on "what does Starter look like". // // inbox / subscription → Starter (any paid tier) -// advanced → Business (15k/day + dedicated IPs tier) +// advanced → Business (15k/day + isolated sending tier) const REQUIRES_TO_MIN_PLAN: Record, PlanID> = { inbox: "starter", subscription: "starter", diff --git a/web/src/hooks/useFeatureAccess.ts b/web/src/hooks/useFeatureAccess.ts index d72a15d8..27cd1ab7 100644 --- a/web/src/hooks/useFeatureAccess.ts +++ b/web/src/hooks/useFeatureAccess.ts @@ -5,8 +5,8 @@ // free → no active subscription // starter → $29/mo, 150 sends/day // grow → $89/mo, 3k sends/day -// business → $329/mo, 15k sends/day + dedicated IPs (featured) -// enterprise → custom, 15k+ sends/day + dedicated IPs +// business → $329/mo, 15k sends/day + isolated sending (featured) +// enterprise → custom, 15k+ sends/day + isolated sending // // Gates here decide which dashboard features show up in the sidebar // + which surfaces render the LockedSurface overlay. The minimum @@ -46,8 +46,8 @@ export interface FeatureAccess { hasInbox: boolean; /** Advanced outreach (AB tests, custom rules) — Business+. */ hasAdvanced: boolean; - /** Dedicated IP pool — Business+. */ - hasDedicatedIps: boolean; + /** Sending on infrastructure bound to this org alone — Business+. */ + hasIsolatedSending: boolean; /** Realtime websocket events — every tier, baseline. */ hasRealtime: boolean; /** Bulk import/edit on contacts — Starter+. */ @@ -88,7 +88,7 @@ export default function useFeatureAccess(): FeatureAccess { locked: false, hasInbox: true, hasAdvanced: true, - hasDedicatedIps: true, + hasIsolatedSending: true, hasRealtime: true, hasBulkOps: true, hasTeam: true, @@ -123,7 +123,7 @@ export default function useFeatureAccess(): FeatureAccess { // names like "Pro" / "Free Trial" and would wrongly lock paid orgs. hasInbox: isPaid, hasAdvanced: isPaid && isAtLeast(plan, "business"), - hasDedicatedIps: isPaid && isAtLeast(plan, "business"), + hasIsolatedSending: isPaid && isAtLeast(plan, "business"), hasRealtime: true, hasBulkOps: isPaid && isAtLeast(plan, "starter"), hasTeam: isPaid && isAtLeast(plan, "starter"), diff --git a/web/src/lib/plans.ts b/web/src/lib/plans.ts index e2041aa5..068b77e2 100644 --- a/web/src/lib/plans.ts +++ b/web/src/lib/plans.ts @@ -22,9 +22,12 @@ export interface PlanDef { /** Marketing-page bullets shown in the upgrade card + billing page. */ bullets: string[]; /** Dashboard accent color for the PlanPill + sidebar badges. */ - accent: "slate" | "green" | "orange" | "indigo" | "gradient"; - /** Whether this plan unlocks dedicated IPs. */ - dedicatedIps: boolean; + accent: "slate" | "green" | "orange" | "sky" | "ink"; + /** Whether this plan's sending runs on infrastructure bound to this + * organization alone (plan.DedicatedWorkers > 0 server-side). What it + * buys is reputation isolation, not an IP-management product, so never + * describe it as a "dedicated IP". */ + isolatedSending: boolean; /** Featured / "most popular" on the pricing page. */ featured?: boolean; } @@ -39,7 +42,7 @@ export const PLAN_CATALOG: Record = { sendsPerDay: 0, bullets: ["Up to 10 mailboxes with warmup", "Link self-hosted instances", "No sending"], accent: "slate", - dedicatedIps: false, + isolatedSending: false, }, starter: { id: "starter", @@ -50,7 +53,7 @@ export const PLAN_CATALOG: Record = { sendsPerDay: 150, bullets: ["Unlimited warmup", "Unlimited mailboxes", "150 emails / day"], accent: "green", - dedicatedIps: false, + isolatedSending: false, }, grow: { id: "grow", @@ -61,7 +64,7 @@ export const PLAN_CATALOG: Record = { sendsPerDay: 3_000, bullets: ["Unlimited warmup", "Unlimited mailboxes", "3,000 emails / day"], accent: "orange", - dedicatedIps: false, + isolatedSending: false, }, business: { id: "business", @@ -74,10 +77,10 @@ export const PLAN_CATALOG: Record = { "Unlimited warmup", "Unlimited mailboxes", "15,000 emails / day", - "Dedicated IPs", + "Sending kept apart from other customers", ], - accent: "indigo", - dedicatedIps: true, + accent: "sky", + isolatedSending: true, featured: true, }, enterprise: { @@ -91,11 +94,11 @@ export const PLAN_CATALOG: Record = { "Unlimited warmup", "Unlimited mailboxes", "15,000+ emails / day", - "Dedicated IPs", + "Sending kept apart from other customers", "Dedicated support", ], - accent: "gradient", - dedicatedIps: true, + accent: "ink", + isolatedSending: true, }, }; @@ -151,20 +154,20 @@ export const PLAN_ACCENT_CLASSES: Record