diff --git a/src/components/Sidebar/PlanUsageBadge.react.js b/src/components/Sidebar/PlanUsageBadge.react.js
index d24ffea60..90495f701 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 6b6bcb148..e93cf893d 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 230ea87c8..73ba225e5 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) : }
- {open ? setOpen(false)} /> : null}
+ {open ? setOpen(false)} /> : null}
>
);
};
diff --git a/src/components/UsageLimitBanner/UsageLimitBanner.react.js b/src/components/UsageLimitBanner/UsageLimitBanner.react.js
index 0865242fb..c8cd4557f 100644
--- a/src/components/UsageLimitBanner/UsageLimitBanner.react.js
+++ b/src/components/UsageLimitBanner/UsageLimitBanner.react.js
@@ -7,8 +7,9 @@ import { getUsageAlert } from 'dashboard/Data/AppOverview/usageAlert';
import styles from 'components/UsageLimitBanner/UsageLimitBanner.scss';
// Near or over a plan limit (Overview and Plan Usage). The wording and the action come from
-// getUsageAlert: Free opens the MVP checkout; paid plans get a link to Plan Usage naming the
-// next plan up, unless they are already on it (showPlansLink={false}).
+// getUsageAlert: the button opens the checkout of the next plan up (MVP on Free, Pay As You Go
+// on MVP, ...). Plans with no next plan to name get a link to Plan Usage, unless they are
+// already on it (showPlansLink={false}).
const UsageLimitBanner = ({ planData, blocked, slug, showPlansLink = true }) => {
const alert = getUsageAlert(planData, blocked);
if (!alert) {
@@ -16,7 +17,7 @@ const UsageLimitBanner = ({ planData, blocked, slug, showPlansLink = true }) =>
}
let action = null;
if (alert.action === 'checkout') {
- action = ;
+ action = ;
} else if (showPlansLink) {
action = {alert.actionLabel};
}
diff --git a/src/components/UsageLimitBanner/UsageLimitBanner.scss b/src/components/UsageLimitBanner/UsageLimitBanner.scss
index 66510c0e5..6de04b73f 100644
--- a/src/components/UsageLimitBanner/UsageLimitBanner.scss
+++ b/src/components/UsageLimitBanner/UsageLimitBanner.scss
@@ -1,8 +1,7 @@
@import 'stylesheets/globals.scss';
@import 'stylesheets/back4app.scss';
-// Yellow above 70% (always yellow on MVP), red above 90% on Free or when the app is blocked,
-// neutral for Pay As You Go over its included usage.
+// Yellow above 70%, red above 90% or when the app is blocked, on every plan.
.banner {
display: flex;
align-items: center;
@@ -35,15 +34,6 @@
}
}
-.info {
- border-color: rgba(255, 255, 255, 0.2);
- background: rgba(255, 255, 255, 0.04);
-
- svg {
- color: #9aa5b8;
- }
-}
-
.text {
flex: 1;
min-width: 0;
diff --git a/src/dashboard/Data/AppOverview/AppOverview.react.js b/src/dashboard/Data/AppOverview/AppOverview.react.js
index 1b2bae733..67846ba8b 100644
--- a/src/dashboard/Data/AppOverview/AppOverview.react.js
+++ b/src/dashboard/Data/AppOverview/AppOverview.react.js
@@ -20,7 +20,7 @@ import B4aTooltip from 'components/Tooltip/B4aTooltip.react';
import OnboardingBoxes from './OnboardingBoxes.react';
import AccountManager from 'lib/AccountManager';
import { amplitudeLogEvent } from 'lib/amplitudeEvents';
-import { UpgradeGate, UpgradeGateView, logGateClicked, planUsagePath } from 'lib/upgradeEvents';
+import { UpgradeGate } from 'lib/upgradeEvents';
import UsageLimitBanner from 'components/UsageLimitBanner/UsageLimitBanner.react';
import RegionChange from './RegionChange.react';
import { UpgradeGateButton } from 'components/UpgradeCheckout/UpgradeCheckout.react';
@@ -344,15 +344,10 @@ class AppOverview extends DashboardView {
renderTrigger={open => Enable web hosting}
/>
) : httpsLocked ? (
- // Already paying for MVP: compare plans instead of opening a second subscription.
- logGateClicked(UpgradeGate.HTTPS, this.context.applicationId)}
- >
-
- Enable HTTPS
-
+ Enable HTTPS}
+ />
) : (
{!webUrl ? 'Enable web hosting' : customDomain ? 'Manage' : 'Use your domain'}
@@ -389,7 +384,7 @@ class AppOverview extends DashboardView {
diff --git a/src/dashboard/Data/AppOverview/AppPlanCard.react.js b/src/dashboard/Data/AppOverview/AppPlanCard.react.js
index b9592eecd..cfa575937 100644
--- a/src/dashboard/Data/AppOverview/AppPlanCard.react.js
+++ b/src/dashboard/Data/AppOverview/AppPlanCard.react.js
@@ -2,8 +2,8 @@ import React from 'react';
import styles from 'dashboard/Data/AppOverview/AppOverview.scss';
import { Button } from '@back4app2/react-components';
import Icon from 'components/Icon/Icon.react';
-import { Link } from 'react-router-dom';
-import { UpgradeGate, logGateClicked, planUsagePath } from 'lib/upgradeEvents';
+import { UpgradeGate } from 'lib/upgradeEvents';
+import { upgradeTargetOf } from 'dashboard/Data/AppOverview/usageAlert';
import { UpgradeGateButton } from 'components/UpgradeCheckout/UpgradeCheckout.react';
const formatDate = (dateString) => {
@@ -19,10 +19,10 @@ const formatDate = (dateString) => {
}
}
-const AppPlanCard = ({ loading, planData, appSlug, appId }) => {
- // Free has a single next step (MVP), so its button opens that checkout directly.
- // Paid plans still go to the plan page to compare the next tier.
- const isFree = !loading && planData && !(planData instanceof Error) && /Free/i.test(planData.planName || '');
+const AppPlanCard = ({ loading, planData }) => {
+ // The button opens the checkout of the next plan up (MVP on Free). Plans with no next plan
+ // to name go to the plan page.
+ const nextPlan = loading ? undefined : upgradeTargetOf(planData);
let content = null;
if (loading) {
content =
{content}
diff --git a/src/dashboard/Data/AppOverview/usageAlert.js b/src/dashboard/Data/AppOverview/usageAlert.js
index 751b0e468..533068da4 100644
--- a/src/dashboard/Data/AppOverview/usageAlert.js
+++ b/src/dashboard/Data/AppOverview/usageAlert.js
@@ -38,7 +38,7 @@ const resourcesOf = planData => [
const planNameOf = planData => (planData && !(planData instanceof Error) && planData.planName) || '';
-// Free is blocked over a limit; MVP and Pay As You Go keep running and pay the overage.
+// Free is blocked over a limit; paid plans keep running and pay the overage.
// Legacy plans are grouped with the current plan of their size: Solo and Starter upgrade to
// MVP ('starter'), Basic, Intermediate and Standard follow MVP, Advanced follows Pay As You Go.
// Dedicated, Silver, Gold, Platinum and custom plans get no usage warnings.
@@ -85,8 +85,14 @@ export const getMostUrgentUsage = planData => {
return resources.length ? { worst: resources[0], others: resources.slice(1) } : null;
};
+// The plan the direct checkout sells from this one, or undefined when there is no next plan up.
+export const upgradeTargetOf = planData => UPGRADE_TARGET[planKindOf(planData)];
+
// Decides the usage banner (Overview and Plan Usage). Returns null when there is nothing to say,
-// or { level, message, action } where action is 'checkout' (MVP checkout) or 'plans' with a label.
+// or { level, message, action, actionLabel }. The action is 'checkout' with the `plan` to sell
+// (the next one up) or, when there is no next plan to name, 'plans' for the Plan Usage page.
+// Every plan follows the Free thresholds: yellow above 70%, red above 90%. Only the consequence
+// changes: Free stops responding at 100%, paid plans keep running and pay the overage.
// The app is really blocked only when the server answers 402 or the plan is paused
// ("Free Plan - Paused"); a Free app over its limit still serves requests until then.
export const getUsageAlert = (planData, blocked = false) => {
@@ -94,90 +100,68 @@ export const getUsageAlert = (planData, blocked = false) => {
const usage = getMostUrgentUsage(planData);
const worst = usage && usage.worst;
const paused = /paused/i.test(planNameOf(planData));
- const upgradeLabel = UPGRADE_TARGET[kind] ? `Upgrade to ${UPGRADE_TARGET[kind]}` : 'Upgrade plan';
+ const target = UPGRADE_TARGET[kind];
+ const upgrade = target
+ ? { action: 'checkout', plan: target, actionLabel: `Upgrade to ${target}` }
+ : { action: 'plans', actionLabel: 'Upgrade plan' };
if (blocked || paused) {
return {
level: 'blocked',
message: blockedMessage(worst && worst.percent >= 100 ? worst : null),
- action: kind === 'free' ? 'checkout' : 'plans',
- actionLabel: kind === 'free' ? 'Upgrade to bring it back' : upgradeLabel,
+ ...upgrade,
+ ...(kind === 'free' ? { actionLabel: 'Upgrade to bring it back' } : {}),
};
}
- if (!worst) {
+ if (!worst || !target || worst.percent <= WARNING_PERCENT) {
return null;
}
+ const level = worst.percent > DANGER_PERCENT ? 'danger' : 'warning';
const percent = Math.floor(worst.percent);
const amount = `(${worst.used} of ${worst.limit})`;
if (kind === 'free') {
- if (worst.percent <= WARNING_PERCENT) {
- return null;
- }
// Over the limit but not paused yet: urgent and honest, the app can stop at any moment.
if (worst.percent >= 100) {
return {
- level: 'danger',
+ level,
message: `Your app reached its ${worst.label} limit and can stop responding at any moment. Upgrade now to keep it running.`,
- action: 'checkout',
+ ...upgrade,
actionLabel: 'Upgrade to keep it running',
};
}
return {
- level: worst.percent > DANGER_PERCENT ? 'danger' : 'warning',
+ level,
message: `${usageSummary(worst, percent, amount, '')}${othersClose(usage)} At 100%, your app stops responding and your users start getting errors.`,
- action: 'checkout',
- actionLabel: 'Upgrade to MVP',
+ ...upgrade,
};
}
+ // Paid plans keep running over their limits and pay the overage, so the message names the
+ // price instead of an outage. Legacy plans have no published price and point at the next plan up.
const plan = planLabelOf(planData);
-
- // MVP keeps running over its limits and pays the overage: a yellow heads-up, never red.
- // Same for the legacy plans below Pay As You Go, which point at the next plan up.
- if (kind === 'starter' || kind === 'mvp') {
- if (worst.percent <= WARNING_PERCENT) {
- return null;
- }
- const message = worst.percent >= 100
- ? `This app used ${percent}% of its ${plan} ${worst.label} ${amount}.${overageOf(worst, plan, 'It keeps running, and ')}`
- : `${usageSummary(worst, percent, amount, ` on ${plan}`)}${othersClose(usage)}${overageOf(worst, plan, 'Above 100%, ')}`;
- return { level: 'warning', message, action: 'plans', actionLabel: upgradeLabel };
- }
-
- // Pay As You Go already expects overage billing: say nothing until it is over a limit.
- if (kind === 'payg' && worst.percent >= 100) {
- return {
- level: 'info',
- message: `This app is above the ${worst.label} included in ${plan} ${amount}${plan in EXTRA_REQUESTS_PRICE ? `, so ${worst.overage(plan)}` : ''}.`,
- action: 'plans',
- actionLabel: upgradeLabel,
- };
- }
- return null;
+ const message = worst.percent >= 100
+ ? `This app used ${percent}% of its ${plan} ${worst.label} ${amount}.${overageOf(worst, plan, 'It keeps running, and ')}`
+ : `${usageSummary(worst, percent, amount, ` on ${plan}`)}${othersClose(usage)}${overageOf(worst, plan, 'Above 100%, ')}`;
+ return { level, message, ...upgrade };
};
// The sidebar pill next to Plan Usage, following the same rules as the banner.
-// Null when the banner would say nothing (blocked apps aside).
+// Null when the banner would say nothing (blocked apps aside). Free stops at its limit, so its
+// pill caps at 100%; paid plans show how far over they are.
export const getUsageBadge = planData => {
const kind = planKindOf(planData);
const usage = getMostUrgentUsage(planData);
const worst = usage && usage.worst;
- if (!worst) {
+ if (!worst || !UPGRADE_TARGET[kind] || worst.percent <= WARNING_PERCENT) {
return null;
}
const percent = Math.floor(worst.percent);
- if (kind === 'free' && worst.percent > WARNING_PERCENT) {
- return { percent: Math.min(100, percent), level: worst.percent > DANGER_PERCENT ? 'danger' : 'warning' };
- }
- if ((kind === 'starter' || kind === 'mvp') && worst.percent > WARNING_PERCENT) {
- return { percent, level: 'warning' };
- }
- if (kind === 'payg' && worst.percent >= 100) {
- return { percent, level: 'info' };
- }
- return null;
+ return {
+ percent: kind === 'free' ? Math.min(100, percent) : percent,
+ level: worst.percent > DANGER_PERCENT ? 'danger' : 'warning',
+ };
};
const usageSummary = (worst, percent, amount, onPlan) =>
diff --git a/src/dashboard/DomainSettings/DomainSettings.react.js b/src/dashboard/DomainSettings/DomainSettings.react.js
index aadc4ef22..be2e9b1ab 100644
--- a/src/dashboard/DomainSettings/DomainSettings.react.js
+++ b/src/dashboard/DomainSettings/DomainSettings.react.js
@@ -22,8 +22,7 @@ import Field from 'components/Field/Field.react';
import TextInput from 'components/TextInput/TextInput.react';
import Fieldset from 'components/Fieldset/Fieldset.react';
import { amplitudeLogEvent } from 'lib/amplitudeEvents';
-import { UpgradeGate, UpgradeGateView, logGateClicked, planUsagePath } from 'lib/upgradeEvents';
-import { Link } from 'react-router-dom';
+import { UpgradeGate } from 'lib/upgradeEvents';
import { UpgradeGateButton } from 'components/UpgradeCheckout/UpgradeCheckout.react';
import B4aNotification from 'dashboard/Data/Browser/B4aNotification.react';
import browserStyles from 'dashboard/Data/Browser/Browser.scss';
@@ -559,14 +558,7 @@ class DomainSettings extends DashboardView {
/>
>
) : (
- // Whoever reaches this already pays for MVP: compare plans instead of opening a second subscription.
- logGateClicked(UpgradeGate.CUSTOM_DOMAIN, this.context.applicationId)}
- >
-
-
-
+
)}
}
diff --git a/src/lib/tests/PlanUsageBadge.test.js b/src/lib/tests/PlanUsageBadge.test.js
index 4b18c69c4..b641ec578 100644
--- a/src/lib/tests/PlanUsageBadge.test.js
+++ b/src/lib/tests/PlanUsageBadge.test.js
@@ -53,25 +53,32 @@ describe('PlanUsageBadge', () => {
describe('PlanUsageBadge on paid plans', () => {
beforeEach(() => clearPlanDataCache());
- it('MVP stays yellow, even past 100%, and shows the real percentage', async () => {
+ it('MVP follows the Free colors and shows the real percentage past 100%', async () => {
+ const yellow = await mount(plan({ planName: 'MVP Plan', apiCallUsed: '400 K', apiCallLimit: '500 K' }));
+ expect(yellow.props.className).toBe('badge warning');
+ clearPlanDataCache();
const badge = await mount(plan({ planName: 'MVP Plan', apiCallUsed: '520 K', apiCallLimit: '500 K' }));
expect(badge.children).toEqual(['104', '%']);
- expect(badge.props.className).toBe('badge warning');
+ expect(badge.props.className).toBe('badge danger');
});
- it('Pay As You Go shows nothing until it is over its included usage, then a neutral pill', async () => {
- expect(await mount(plan({ planName: 'Pay as you go Plan', apiCallUsed: '4.9 M', apiCallLimit: '5 M' }))).toBe(null);
+ it('Pay As You Go follows the same thresholds', async () => {
+ expect(await mount(plan({ planName: 'Pay as you go Plan', apiCallUsed: '3.5 M', apiCallLimit: '5 M' }))).toBe(null);
+ clearPlanDataCache();
+ const yellow = await mount(plan({ planName: 'Pay as you go Plan', apiCallUsed: '4 M', apiCallLimit: '5 M' }));
+ expect(yellow.props.className).toBe('badge warning');
clearPlanDataCache();
const badge = await mount(plan({ planName: 'Pay as you go Plan', apiCallUsed: '5.5 M', apiCallLimit: '5 M' }));
expect(badge.children).toEqual(['110', '%']);
- expect(badge.props.className).toBe('badge info');
+ expect(badge.props.className).toBe('badge danger');
});
it('legacy plans follow the plan of their size', async () => {
const starter = await mount(plan({ planName: 'Starter Plan', apiCallUsed: '49 K', apiCallLimit: '50 K' }));
- expect(starter.props.className).toBe('badge warning');
+ expect(starter.props.className).toBe('badge danger');
clearPlanDataCache();
- expect(await mount(plan({ planName: 'Advanced Plan', apiCallUsed: '4.9 M', apiCallLimit: '5 M' }))).toBe(null);
+ const advanced = await mount(plan({ planName: 'Advanced Plan', apiCallUsed: '4 M', apiCallLimit: '5 M' }));
+ expect(advanced.props.className).toBe('badge warning');
clearPlanDataCache();
expect(await mount(plan({ planName: 'Gold Plan', apiCallUsed: '21 M', apiCallLimit: '20 M' }))).toBe(null);
});
diff --git a/src/lib/tests/UpgradeCheckout.test.js b/src/lib/tests/UpgradeCheckout.test.js
index caa36ffc7..57f0cf7d3 100644
--- a/src/lib/tests/UpgradeCheckout.test.js
+++ b/src/lib/tests/UpgradeCheckout.test.js
@@ -235,6 +235,22 @@ describe('UpgradeCheckoutModal', () => {
expect(textOf(tree)).toContain('Pay As You Go includes custom domains with HTTPS.');
});
+ it('sells the plan the usage banner names, MVP when it names none', async () => {
+ const free = await mount( {}} />);
+ expect(paddle.Checkout.open.mock.calls[0][0].items[0].priceId).toBe('pri_mvp_y');
+ expect(textOf(free)).toContain('Keep your app running');
+
+ paddle.Checkout.open.mockClear();
+ const mvp = await mount( {}} />);
+ expect(paddle.Checkout.open.mock.calls[0][0].items[0].priceId).toBe('pri_payg_y');
+ expect(textOf(mvp)).toContain('Pay As You Go raises your limits');
+
+ paddle.Checkout.open.mockClear();
+ const payg = await mount( {}} />);
+ expect(paddle.Checkout.open.mock.calls[0][0].items[0].priceId).toBe('pri_ded_y');
+ expect(textOf(payg)).toContain('Dedicated has unlimited requests');
+ });
+
it('offers the plan that actually unlocks each compliance badge', async () => {
await mount( {}} />);
expect(paddle.Checkout.open.mock.calls[0][0].items[0].priceId).toBe('pri_payg_y');
@@ -273,21 +289,67 @@ describe('UpgradeGateButton', () => {
expect(textOf(tree)).toContain('Faster queries and the newest MongoDB features.');
});
- it('sends apps already on a paid plan to Plan Usage instead of a second subscription', async () => {
- app = appOnPlan('Starter Plan');
+ // Clicks the trigger on an app of the given plan; returns where it went.
+ const clickOn = async (planName, props) => {
+ app = appOnPlan(planName);
const assign = jest.fn();
global.window = { location: { assign } };
const tree = await mount(
- Upgrade Plan} />
+ Upgrade Plan} />
);
await renderer.act(async () => {
await tree.root.findByProps({ id: 'trigger' }).props.onClick({ preventDefault: () => {} });
});
delete global.window;
+ return { assign, text: textOf(tree) };
+ };
- expect(assign).toHaveBeenCalledWith('/apps/my-app/plan-usage?gate=collaborators');
- expect(initPaddle).not.toHaveBeenCalled();
- expect(loggedEvents('baas_upgrade_gate_clicked')).toEqual([{ gate: 'collaborators', app_id: 'app-1' }]);
+ it('sends apps at or above the plan it sells to Plan Usage instead of a lower plan', async () => {
+ for (const planName of ['MVP Plan', 'Pay as you go Plan', 'Gold Plan']) {
+ clearPlanDataCache();
+ initPaddle.mockClear();
+ const { assign } = await clickOn(planName, { gate: 'collaborators' });
+ expect(assign).toHaveBeenCalledWith('/apps/my-app/plan-usage?gate=collaborators');
+ expect(initPaddle).not.toHaveBeenCalled();
+ }
+ expect(loggedEvents('baas_upgrade_gate_clicked')).toContainEqual({ gate: 'collaborators', app_id: 'app-1' });
+ });
+
+ it('opens the checkout on paid plans below the plan it sells', async () => {
+ const starter = await clickOn('Starter Plan', { gate: 'collaborators' });
+ expect(starter.assign).not.toHaveBeenCalled();
+ expect(starter.text).toContain('Bring your team into this app');
+
+ clearPlanDataCache();
+ const mvp = await clickOn('MVP Plan', { gate: 'https' });
+ expect(mvp.assign).not.toHaveBeenCalled();
+ expect(mvp.text).toContain('Serve your custom domain over HTTPS');
+ });
+
+ it('opens the checkout of the next plan up from the Overview plan card', async () => {
+ const mvp = await clickOn('MVP Plan', { gate: 'overview_plan_card', plan: 'Pay As You Go' });
+ expect(mvp.assign).not.toHaveBeenCalled();
+ expect(mvp.text).toContain('Give this app room to scale');
+
+ clearPlanDataCache();
+ const payg = await clickOn('Pay as you go Plan', { gate: 'overview_plan_card', plan: 'Dedicated' });
+ expect(payg.text).toContain('Run this app on dedicated resources');
+
+ // No next plan up: nothing to sell directly.
+ clearPlanDataCache();
+ const dedicated = await clickOn('Dedicated Plan', { gate: 'overview_plan_card', plan: undefined });
+ expect(dedicated.assign).toHaveBeenCalledWith('/apps/my-app/plan-usage?gate=overview_plan_card');
+ });
+
+ it('opens the checkout of the next plan up from the usage banner', async () => {
+ const mvp = await clickOn('MVP Plan', { gate: 'usage_limit', plan: 'Pay As You Go' });
+ expect(mvp.assign).not.toHaveBeenCalled();
+ expect(mvp.text).toContain('Pay As You Go raises your limits');
+
+ clearPlanDataCache();
+ const payg = await clickOn('Pay as you go Plan', { gate: 'usage_limit', plan: 'Dedicated' });
+ expect(payg.assign).not.toHaveBeenCalled();
+ expect(payg.text).toContain('Dedicated has unlimited requests');
});
});
diff --git a/src/lib/tests/usageAlert.test.js b/src/lib/tests/usageAlert.test.js
index 4c09cbfd6..3aa068fb4 100644
--- a/src/lib/tests/usageAlert.test.js
+++ b/src/lib/tests/usageAlert.test.js
@@ -60,6 +60,8 @@ describe('getUsageAlert', () => {
'Your app reached its API requests limit and can stop responding at any moment. Upgrade now to keep it running.'
);
expect(alert.actionLabel).toBe('Upgrade to keep it running');
+ expect(alert.action).toBe('checkout');
+ expect(alert.plan).toBe('MVP');
expect(alert.message).not.toMatch(/stopped/);
});
@@ -80,19 +82,25 @@ describe('getUsageAlert', () => {
describe('MVP', () => {
const mvp = overrides => plan({ planName: 'MVP Plan', apiCallLimit: '500 K', dataStorageLimit: '1 GB', fileStorageLimit: '50 GB', ...overrides });
- it('warns in yellow, never red, and names the overage price', () => {
+ it('follows the Free thresholds: quiet up to 70%, yellow above it, red above 90%', () => {
+ expect(getUsageAlert(mvp({ apiCallUsed: '350 K' }))).toBe(null);
+ expect(getUsageAlert(mvp({ apiCallUsed: '400 K' })).level).toBe('warning');
+ expect(getUsageAlert(mvp({ apiCallUsed: '490 K' })).level).toBe('danger');
+ });
+
+ it('names the overage price and opens the Pay As You Go checkout', () => {
const alert = getUsageAlert(mvp({ apiCallUsed: '490 K' }));
- expect(alert.level).toBe('warning');
expect(alert.message).toBe(
'You\'ve used 98% of this month\'s API requests on MVP (490 K of 500 K). Above 100%, extra requests are billed at $5 per 100K.'
);
- expect(alert.action).toBe('plans');
+ expect(alert.action).toBe('checkout');
+ expect(alert.plan).toBe('Pay As You Go');
expect(alert.actionLabel).toBe('Upgrade to Pay As You Go');
});
- it('past 100% it keeps running and pays the overage, still yellow', () => {
+ it('past 100% it keeps running and pays the overage', () => {
const alert = getUsageAlert(mvp({ apiCallUsed: '520 K' }));
- expect(alert.level).toBe('warning');
+ expect(alert.level).toBe('danger');
expect(alert.message).toBe(
'This app used 104% of its MVP API requests (520 K of 500 K). It keeps running, and extra requests are billed at $5 per 100K.'
);
@@ -104,24 +112,35 @@ describe('getUsageAlert', () => {
expect(getUsageAlert(mvp({ fileStorageUsed: '55 GB' })).message).toContain('extra file storage is billed at $1 per 10 GB');
});
- it('is red only when the server really blocks it', () => {
- expect(getUsageAlert(mvp({ apiCallUsed: '520 K' }), true).level).toBe('blocked');
+ it('a blocked app is offered the Pay As You Go checkout', () => {
+ const alert = getUsageAlert(mvp({ apiCallUsed: '520 K' }), true);
+ expect(alert.level).toBe('blocked');
+ expect(alert.action).toBe('checkout');
+ expect(alert.plan).toBe('Pay As You Go');
});
});
describe('Pay As You Go', () => {
const payg = overrides => plan({ planName: 'Pay as you go Plan', apiCallLimit: '5 M', dataStorageLimit: '3 GB', fileStorageLimit: '250 GB', ...overrides });
- it('says nothing while under its included usage', () => {
- expect(getUsageAlert(payg({ apiCallUsed: '4.9 M' }))).toBe(null);
+ it('follows the Free thresholds and names its overage price', () => {
+ expect(getUsageAlert(payg({ apiCallUsed: '3.5 M' }))).toBe(null);
+ const alert = getUsageAlert(payg({ apiCallUsed: '4 M' }));
+ expect(alert.level).toBe('warning');
+ expect(alert.message).toBe(
+ 'You\'ve used 80% of this month\'s API requests on Pay As You Go (4 M of 5 M). Above 100%, extra requests are billed at $2 per 100K.'
+ );
+ expect(getUsageAlert(payg({ apiCallUsed: '4.9 M' })).level).toBe('danger');
});
- it('over its included usage, explains the overage and offers Dedicated', () => {
+ it('over its included usage, explains the overage and opens the Dedicated checkout', () => {
const alert = getUsageAlert(payg({ apiCallUsed: '5.5 M' }));
- expect(alert.level).toBe('info');
+ expect(alert.level).toBe('danger');
expect(alert.message).toBe(
- 'This app is above the API requests included in Pay As You Go (5.5 M of 5 M), so extra requests are billed at $2 per 100K.'
+ 'This app used 110% of its Pay As You Go API requests (5.5 M of 5 M). It keeps running, and extra requests are billed at $2 per 100K.'
);
+ expect(alert.action).toBe('checkout');
+ expect(alert.plan).toBe('Dedicated');
expect(alert.actionLabel).toBe('Upgrade to Dedicated');
});
});
@@ -129,11 +148,12 @@ describe('getUsageAlert', () => {
describe('legacy plans', () => {
const legacy = (planName, overrides) => plan({ planName, apiCallLimit: '50 K', dataStorageLimit: '1 GB', fileStorageLimit: '10 GB', ...overrides });
- it('Solo and Starter warn in yellow and offer MVP, without an overage price', () => {
+ it('Solo and Starter open the MVP checkout, without an overage price', () => {
['Solo Plan', 'Starter Plan'].forEach(planName => {
const alert = getUsageAlert(legacy(planName, { apiCallUsed: '49 K' }));
- expect(alert.level).toBe('warning');
- expect(alert.action).toBe('plans');
+ expect(alert.level).toBe('danger');
+ expect(alert.action).toBe('checkout');
+ expect(alert.plan).toBe('MVP');
expect(alert.actionLabel).toBe('Upgrade to MVP');
expect(alert.message).not.toMatch(/billed/);
});
@@ -149,26 +169,31 @@ describe('getUsageAlert', () => {
['Basic Plan', 'Intermediate Plan', 'Standard Plan'].forEach(planName => {
const alert = getUsageAlert(legacy(planName, { apiCallUsed: '40 K' }));
expect(alert.level).toBe('warning');
+ expect(alert.plan).toBe('Pay As You Go');
expect(alert.actionLabel).toBe('Upgrade to Pay As You Go');
expect(alert.message).not.toMatch(/billed/);
});
});
- it('Advanced follows Pay As You Go: quiet until over its limit, then the same overage message', () => {
+ it('Advanced follows Pay As You Go: same thresholds, overage price and next plan', () => {
const advanced = overrides => legacy('Advanced Plan', { apiCallLimit: '5 M', dataStorageLimit: '4 GB', fileStorageLimit: '250 GB', ...overrides });
- expect(getUsageAlert(advanced({ apiCallUsed: '4.9 M' }))).toBe(null);
+ expect(getUsageAlert(advanced({ apiCallUsed: '3.5 M' }))).toBe(null);
+ expect(getUsageAlert(advanced({ apiCallUsed: '4 M' })).level).toBe('warning');
const alert = getUsageAlert(advanced({ apiCallUsed: '5.5 M' }));
- expect(alert.level).toBe('info');
+ expect(alert.level).toBe('danger');
expect(alert.message).toBe(
- 'This app is above the API requests included in Advanced (5.5 M of 5 M), so extra requests are billed at $2 per 100K.'
+ 'This app used 110% of its Advanced API requests (5.5 M of 5 M). It keeps running, and extra requests are billed at $2 per 100K.'
);
- expect(alert.actionLabel).toBe('Upgrade to Dedicated');
- expect(getUsageAlert(advanced({ dataStorageUsed: '4.5 GB' })).message).toContain('so extra database storage is billed at $15 per GB');
+ expect(alert.plan).toBe('Dedicated');
+ expect(getUsageAlert(advanced({ dataStorageUsed: '4.5 GB' })).message).toContain('and extra database storage is billed at $15 per GB');
});
it('a blocked legacy app is told which plan to move to', () => {
expect(getUsageAlert(legacy('Starter Plan'), true).actionLabel).toBe('Upgrade to MVP');
- expect(getUsageAlert(legacy('Gold Plan'), true).actionLabel).toBe('Upgrade plan');
+ // No next plan to name: Plan Usage instead of a checkout.
+ const gold = getUsageAlert(legacy('Gold Plan'), true);
+ expect(gold.action).toBe('plans');
+ expect(gold.actionLabel).toBe('Upgrade plan');
});
});