feat: drop the purple gradient and indigo plan accents for the app's own slate and sky palette (Business wears sky, Enterprise solid ink, no gradients anywhere), highlight the best-value plan instead of whatever sits one rung up, remove the duplicated capability matrix from Overview and the comparison table from Plans, and stop calling dedicated workers 'dedicated IPs' across the dashboard, docs, pricing page, FAQ and agencies page

This commit is contained in:
Matthew Meszaros
2026-09-04 05:11:47 -07:00
parent 01dd6e3450
commit 87ada2c99f
11 changed files with 53 additions and 370 deletions
+2 -3
View File
@@ -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/).
+1 -1
View File
@@ -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.'],
],
},
+3 -1
View File
@@ -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',
],
+2 -2
View File
@@ -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 = [
<div class="max-w-2xl mb-12">
<div class="text-[11px] uppercase tracking-[0.18em] font-mono text-[#0284c7] mb-3">Sending infrastructure</div>
<h2 class="text-[32px] md:text-[44px] font-semibold tracking-[-0.03em] leading-[1.05] text-heading">
Shared premium pool, or dedicated IPs per client.
Shared premium pool, or sending kept apart per client.
</h2>
<p class="mt-4 text-[15.5px] text-foreground/70 leading-relaxed">
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.
@@ -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
</p>
</Section>
{/* ── Capability matrix ────────────────────────────────────── */}
<Section
eyebrow="What's included"
description="Exactly which capabilities this plan turns on. Locked rows open the plan chooser."
>
{features.isPending ? (
<div className="h-40 rounded bg-slate-100 animate-pulse" />
) : (
<div className="rounded-lg border border-slate-200 divide-y divide-slate-200/70 overflow-hidden">
<FeatureRow
label="Mailbox warmup"
hint="Keeps mailboxes conversational and out of spam"
on={features.data?.can_use_warmup ?? access.paid}
minPlan="starter"
onUpgrade={onChangePlan}
/>
<FeatureRow
label="Campaign sending"
hint="Sequences, scheduling and per-mailbox pacing"
on={features.data?.can_send_campaigns ?? access.paid}
minPlan="starter"
onUpgrade={onChangePlan}
/>
<FeatureRow
label="Unified inbox"
hint="Every reply across every mailbox in one place"
on={features.data?.can_use_unibox ?? access.hasInbox}
minPlan="starter"
onUpgrade={onChangePlan}
/>
<FeatureRow
label="Team members"
hint="Invite teammates and assign roles"
on={access.hasTeam}
minPlan="starter"
onUpgrade={onChangePlan}
/>
<FeatureRow
label="Bulk contact operations"
hint="Import, bulk edit and bulk enrol"
on={access.hasBulkOps}
minPlan="starter"
onUpgrade={onChangePlan}
/>
<FeatureRow
label="Webhooks"
hint="Signed event delivery to your endpoints"
on={access.hasWebhooks}
minPlan="business"
onUpgrade={onChangePlan}
/>
<FeatureRow
label="Advanced outreach"
hint="A/B tests and custom sending rules"
on={access.hasAdvanced}
minPlan="business"
onUpgrade={onChangePlan}
/>
<FeatureRow
label="Dedicated IPs"
hint="An isolated sending pool for this workspace"
on={access.hasDedicatedIps}
minPlan="business"
onUpgrade={onChangePlan}
/>
</div>
)}
</Section>
</>
);
}
@@ -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 (
<div className="px-3.5 py-2.5 flex items-center gap-3 bg-white">
<span
className={`size-5 rounded flex items-center justify-center shrink-0 ${
on ? "bg-emerald-50 text-emerald-600" : "bg-slate-100 text-slate-400"
}`}
>
{on ? <CheckIcon className="w-3 h-3" /> : <LockIcon className="w-3 h-3" />}
</span>
<div className="min-w-0 flex-1">
<div className={`text-[12.5px] font-medium ${on ? "text-slate-900" : "text-slate-500"}`}>
{label}
</div>
<div className="text-[11px] text-slate-400 leading-snug truncate">{hint}</div>
</div>
{on ? (
<span className="text-[10px] uppercase tracking-[0.08em] font-semibold text-emerald-700 bg-emerald-50 border border-emerald-100 rounded px-1.5 py-0.5 shrink-0">
On
</span>
) : (
<button
type="button"
onClick={onUpgrade}
className={`text-[10px] uppercase tracking-[0.08em] font-semibold rounded px-1.5 py-0.5 border shrink-0 transition-transform hover:scale-105 ${accent.pill}`}
>
{plan.label}
</button>
)}
</div>
);
}
function StatusPill({
status,
cancelAtEnd,
+4 -15
View File
@@ -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() {
</Link>
</Section>
<Section
eyebrow="Everything side by side"
description="Limits are the ones this instance actually enforces, read from the configured plans; anything unset shows the published number."
>
<PlanComparison
current={currentPlan.id}
interval={billingInterval}
resolveServerPlan={flow.resolveServerPlan}
/>
</Section>
<Section
eyebrow="Promo code"
description="Have a discount code? Apply it to preview your price. It's applied at checkout."
+4 -3
View File
@@ -150,7 +150,7 @@ export default function PlanCard({
)}
>
<SparklesIcon className="w-2.5 h-2.5" />
{feature ? `Unlocks ${feature}` : "Recommended"}
{feature ? `Unlocks ${feature}` : "Best value"}
</motion.span>
)}
@@ -163,8 +163,9 @@ export default function PlanCard({
<span className="ml-auto text-[9px] uppercase tracking-[0.08em] font-semibold text-slate-700 bg-slate-100 border border-slate-200 rounded px-1">
Current
</span>
) : plan.featured ? (
<span className="ml-auto text-[9px] uppercase tracking-[0.08em] font-semibold text-indigo-700 bg-indigo-50 border border-indigo-100 rounded px-1">
) : plan.featured && !recommended ? (
// The ribbon already says it; two badges on one card reads as noise.
<span className={cn("ml-auto text-[9px] uppercase tracking-[0.08em] font-semibold border rounded px-1", accent.pill)}>
Popular
</span>
) : 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 = <CheckIcon className="w-3.5 h-3.5 text-emerald-600 mx-auto" />;
const no = <MinusIcon className="w-3.5 h-3.5 text-slate-300 mx-auto" />;
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 (
<div className="overflow-x-auto -mx-1 px-1">
<table className="w-full min-w-[620px] text-[12px] border-separate border-spacing-0">
<thead>
<tr>
<th className="sticky left-0 z-10 bg-white text-left font-medium text-[10.5px] uppercase tracking-[0.08em] text-slate-400 px-3 py-2 border-b border-slate-200">
Feature
</th>
{PAID_PLANS.map((id) => {
const plan = getPlan(id);
const accent = PLAN_ACCENT_CLASSES[plan.accent];
const isCurrent = id === current;
return (
<th
key={id}
className={cn(
"px-3 py-2 border-b border-slate-200 text-center align-bottom",
isCurrent && "bg-slate-50",
)}
>
<span className="inline-flex items-center gap-1.5">
<span className={cn("size-1.5 rounded-full", accent.dot)} />
<span className="text-[11.5px] font-semibold text-slate-800">
{plan.label}
</span>
</span>
{isCurrent && (
<span className="block text-[9px] uppercase tracking-[0.08em] font-semibold text-slate-500 mt-0.5">
Current
</span>
)}
</th>
);
})}
</tr>
</thead>
<tbody>
{ROWS.map((row) => (
<React.Fragment key={row.label}>
{row.group && (
<tr>
<td
colSpan={PAID_PLANS.length + 1}
className="sticky left-0 bg-white px-3 pt-4 pb-1 text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium"
>
{row.group}
</td>
</tr>
)}
<tr className="group">
<th
scope="row"
className="sticky left-0 z-10 bg-white group-hover:bg-slate-50/80 text-left font-normal px-3 py-1.5 border-b border-slate-100 transition-colors"
>
<span className="text-slate-700">{row.label}</span>
{row.hint && (
<span className="block text-[10.5px] text-slate-400 leading-snug">
{row.hint}
</span>
)}
</th>
{PAID_PLANS.map((id) => (
<td
key={id}
className={cn(
"px-3 py-1.5 border-b border-slate-100 text-center tabular-nums text-slate-700 group-hover:bg-slate-50/80 transition-colors",
id === current && "bg-slate-50 font-medium text-slate-900",
)}
>
{row.cell(id, resolveServerPlan(id), interval)}
</td>
))}
</tr>
</React.Fragment>
))}
</tbody>
</table>
</div>
);
}
+1 -1
View File
@@ -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<NonNullable<NavItem["requires"]>, PlanID> = {
inbox: "starter",
subscription: "starter",
+6 -6
View File
@@ -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"),
+29 -26
View File
@@ -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<PlanID, PlanDef> = {
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<PlanID, PlanDef> = {
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<PlanID, PlanDef> = {
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<PlanID, PlanDef> = {
"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<PlanID, PlanDef> = {
"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<PlanDef["accent"], {
soft: "from-amber-50",
button: "bg-amber-600 hover:bg-amber-700 text-white",
},
indigo: {
pill: "bg-indigo-50 text-indigo-700 border-indigo-100",
dot: "bg-indigo-500",
header: "bg-indigo-50 text-indigo-700 border-indigo-100",
ring: "ring-indigo-500 shadow-[0_24px_60px_-24px_rgba(99,102,241,0.55)]",
soft: "from-indigo-50",
button: "bg-indigo-600 hover:bg-indigo-700 text-white",
sky: {
pill: "bg-sky-50 text-sky-700 border-sky-100",
dot: "bg-sky-500",
header: "bg-sky-50 text-sky-700 border-sky-100",
ring: "ring-sky-500 shadow-[0_24px_60px_-24px_rgba(14,165,233,0.4)]",
soft: "from-sky-50",
button: "bg-sky-600 hover:bg-sky-700 text-white",
},
gradient: {
pill: "bg-gradient-to-r from-blue-50 to-purple-50 text-blue-700 border-blue-100",
dot: "bg-gradient-to-r from-blue-500 to-purple-500",
header: "bg-gradient-to-r from-blue-50 to-purple-50 text-blue-700 border-blue-100",
ring: "ring-purple-500 shadow-[0_24px_60px_-24px_rgba(147,51,234,0.5)]",
soft: "from-purple-50",
button: "bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white",
ink: {
pill: "bg-slate-900 text-white border-slate-900",
dot: "bg-slate-900",
header: "bg-slate-900 text-white border-slate-900",
ring: "ring-slate-900 shadow-[0_24px_60px_-24px_rgba(15,23,42,0.45)]",
soft: "from-slate-100",
button: "bg-slate-900 hover:bg-slate-800 text-white",
},
};