From 4005b940b87126795e6f500084876de9946d23e4 Mon Sep 17 00:00:00 2001 From: charles-ramos Date: Wed, 7 Oct 2026 10:44:21 -0300 Subject: [PATCH] Direct checkout for the next plan up on paid plans; usage warnings follow the Free rules Upgrade entry points on paid apps went to Plan Usage, and only Free got the direct Paddle checkout. UpgradeGateButton now opens the checkout whenever the plan it sells is above the app's plan (Solo/Starter -> MVP, MVP -> Pay As You Go, Pay As You Go/Advanced -> Dedicated). Apps at or above the plan a paywall sells, and plans with no next plan up (Gold, Dedicated, custom), still go to Plan Usage. - Usage banner: the button opens the checkout of the next plan up, with a headline and limits written for that plan. Also shown on Plan Usage. - Overview: "Enable HTTPS" and the Plan Usage card's "Upgrade Plan" open the checkout; the card sells the next plan up as a whole. - Domain Settings: "Upgrade to Pay As You Go" opens the checkout. - Usage warnings (banner and sidebar pill) use the Free thresholds on every plan: yellow above 70%, red above 90%. Paid plans keep their own wording, since they keep running and pay the overage. The job limit dialog still sends paid apps to Plan Usage. Co-Authored-By: Claude Opus 5.5 --- .../Sidebar/PlanUsageBadge.react.js | 5 +- src/components/Sidebar/PlanUsageBadge.scss | 6 -- .../UpgradeCheckout/UpgradeCheckout.react.js | 66 ++++++++++++--- .../UsageLimitBanner.react.js | 7 +- .../UsageLimitBanner/UsageLimitBanner.scss | 12 +-- .../Data/AppOverview/AppOverview.react.js | 17 ++-- .../Data/AppOverview/AppPlanCard.react.js | 35 ++++---- src/dashboard/Data/AppOverview/usageAlert.js | 82 ++++++++----------- .../DomainSettings/DomainSettings.react.js | 12 +-- src/lib/tests/PlanUsageBadge.test.js | 21 +++-- src/lib/tests/UpgradeCheckout.test.js | 74 +++++++++++++++-- src/lib/tests/usageAlert.test.js | 69 +++++++++++----- 12 files changed, 247 insertions(+), 159 deletions(-) diff --git a/src/components/Sidebar/PlanUsageBadge.react.js b/src/components/Sidebar/PlanUsageBadge.react.js index d24ffea607..90495f7018 100644 --- a/src/components/Sidebar/PlanUsageBadge.react.js +++ b/src/components/Sidebar/PlanUsageBadge.react.js @@ -4,9 +4,8 @@ import { getCachedPlanData } from 'lib/planDataCache'; import { getUsageBadge } from 'dashboard/Data/AppOverview/usageAlert'; import styles from 'components/Sidebar/PlanUsageBadge.scss'; -// "84%" next to Plan Usage in the sidebar, following the banner's rules: Free yellow above 70% -// and red above 90%; MVP yellow above 70%; Pay As You Go neutral once over its included usage. -// Legacy plans follow the plan of their size. +// "84%" next to Plan Usage in the sidebar, following the banner's rules on every plan: +// yellow above 70% and red above 90%. const PlanUsageBadge = () => { const context = useContext(CurrentApp); const [badge, setBadge] = useState(null); diff --git a/src/components/Sidebar/PlanUsageBadge.scss b/src/components/Sidebar/PlanUsageBadge.scss index 6b6bcb1488..e93cf893dc 100644 --- a/src/components/Sidebar/PlanUsageBadge.scss +++ b/src/components/Sidebar/PlanUsageBadge.scss @@ -22,12 +22,6 @@ color: #FBFF3B; } -.info { - border-color: rgba(255, 255, 255, 0.25); - background: rgba(255, 255, 255, 0.06); - color: #c3cad6; -} - .danger { border-color: rgba(232, 92, 62, 0.6); background: rgba(232, 92, 62, 0.12); diff --git a/src/components/UpgradeCheckout/UpgradeCheckout.react.js b/src/components/UpgradeCheckout/UpgradeCheckout.react.js index 230ea87c8e..73ba225e5c 100644 --- a/src/components/UpgradeCheckout/UpgradeCheckout.react.js +++ b/src/components/UpgradeCheckout/UpgradeCheckout.react.js @@ -10,7 +10,7 @@ import { SUPPORT_TICKET_URL, UpgradeEvent, UpgradeGate, UpgradeGateView, logGate import { Cycle, initPaddle, paddlePlanId, paddlePriceId, recordSubscription } from 'lib/paddleCheckout'; import { prices } from 'dashboard/AppPlan/prices'; import { getCachedPlanData } from 'lib/planDataCache'; -import { planKindOf } from 'dashboard/Data/AppOverview/usageAlert'; +import { upgradeTargetOf } from 'dashboard/Data/AppOverview/usageAlert'; import styles from './UpgradeCheckout.scss'; const CHECKOUT_FRAME_CLASS = 'upgrade-checkout-frame'; @@ -55,7 +55,7 @@ const GATE_OFFERS = { headline: 'Get the latest MongoDB', subline: 'Faster queries and the newest MongoDB features.', }, - // Overview usage banner: near or over a Free limit, where the app gets blocked. + // Usage banner on Free, where the app gets blocked. Paid plans: USAGE_LIMIT_OFFERS below. [UpgradeGate.USAGE_LIMIT]: { plan: 'MVP', headline: 'Keep your app running', @@ -96,6 +96,45 @@ const GATE_OFFERS = { [UpgradeGate.COMPLIANCE_HIPAA]: { plan: 'Dedicated', headline: 'Pass your customer\'s security review', subline: 'HIPAA-ready infrastructure, after a signed BAA.' }, }; +// Usage banner on paid plans: the caller names the next plan up, which raises the limits again. +const USAGE_LIMIT_OFFERS = { + 'Pay As You Go': { + plan: 'Pay As You Go', + headline: 'Get more room to grow', + subline: 'Pay As You Go raises your limits to 5M requests, 3 GB of database and 250 GB of files.', + }, + Dedicated: { + plan: 'Dedicated', + headline: 'Stop counting requests', + subline: 'Dedicated has unlimited requests, 8 GB of database and 1 TB of files.', + }, +}; + +// The overview's Plan Usage card on paid plans: the next plan up as a whole. +const NEXT_PLAN_AS_A_WHOLE = { + 'Pay As You Go': { + plan: 'Pay As You Go', + headline: 'Give this app room to scale', + subline: 'Custom domain with HTTPS, SOC 2 and ISO 27001, and 10x the requests.', + }, + Dedicated: { + plan: 'Dedicated', + headline: 'Run this app on dedicated resources', + subline: 'Unlimited requests, point-in-time backups and HIPAA-ready infrastructure.', + }, +}; + +// Gates that sell more than one plan: the caller names the plan, GATE_OFFERS is the default. +const OFFERS_BY_PLAN = { + [UpgradeGate.USAGE_LIMIT]: USAGE_LIMIT_OFFERS, + [UpgradeGate.OVERVIEW_PLAN_CARD]: NEXT_PLAN_AS_A_WHOLE, +}; + +// The offer for a paywall. `plan` picks the plan when the same gate sells more than one. +const offerFor = (gate, plan) => (OFFERS_BY_PLAN[gate] && OFFERS_BY_PLAN[gate][plan]) || GATE_OFFERS[gate]; + +const planIndex = name => prices.findIndex(p => p.name === name); + const detailText = detail => [detail.number, detail.text].filter(Boolean).join(' '); const priceSummary = (plan, cycle) => { @@ -115,9 +154,9 @@ const getOwnerEmail = async context => { }; // Opens the Paddle checkout for the plan that unlocks `gate`, skipping the plan comparison page. -export const UpgradeCheckoutModal = ({ gate, onClose }) => { +export const UpgradeCheckoutModal = ({ gate, plan: planName, onClose }) => { const context = useContext(CurrentApp); - const offer = GATE_OFFERS[gate]; + const offer = offerFor(gate, planName); const plan = prices.find(p => p.name === offer.plan); const [dark] = useState(prefersDark); @@ -276,11 +315,13 @@ const goToPlanUsage = (slug, gate) => { window.location.assign(`${base}${planUsagePath(slug, gate)}`); }; -// Paywall entry point: logs the gate view and, on click, opens the direct checkout. The direct -// checkout starts a new subscription, so it is only for Free apps; an app already on any paid -// plan (MVP, Starter, Gold, ...) goes to Plan Usage instead of buying a second plan. +// Paywall entry point: logs the gate view and, on click, opens the direct checkout when the +// plan it sells is above the app's plan (MVP on Free, Pay As You Go on MVP, ...). Paywalls +// follow the backend permissions, so they also show on plans at or above the one they sell, +// and on plans with no next plan up (Gold, custom, ...): those go to Plan Usage instead of +// buying a plan below their own. `plan` picks the plan when the gate sells more than one. // `renderTrigger(open)` lets anchors and custom buttons keep their look. -export const UpgradeGateButton = ({ gate, renderTrigger, value = 'Upgrade Plan' }) => { +export const UpgradeGateButton = ({ gate, plan, renderTrigger, value = 'Upgrade Plan' }) => { const context = useContext(CurrentApp); const [open, setOpen] = useState(false); const openCheckout = event => { @@ -289,16 +330,19 @@ export const UpgradeGateButton = ({ gate, renderTrigger, value = 'Upgrade Plan' } logGateClicked(gate, context.applicationId); return getCachedPlanData(context) - .then(planData => planKindOf(planData) === 'free') + .then(planData => { + const target = upgradeTargetOf(planData); + return !!target && planIndex(offerFor(gate, plan).plan) >= planIndex(target); + }) .catch(() => false) - .then(isFree => (isFree ? setOpen(true) : goToPlanUsage(context.slug, gate))); + .then(isUpgrade => (isUpgrade ? setOpen(true) : goToPlanUsage(context.slug, gate))); }; return ( <> {renderTrigger ? renderTrigger(openCheckout) :