diff --git a/src/components/Sidebar/B4aSidebar.react.js b/src/components/Sidebar/B4aSidebar.react.js
index 15780bd071..8f0f4a3660 100644
--- a/src/components/Sidebar/B4aSidebar.react.js
+++ b/src/components/Sidebar/B4aSidebar.react.js
@@ -310,10 +310,11 @@ const B4aSidebar = ({
link,
subsections,
badgeParams,
+ badge: sectionBadge,
headerAction
}) => {
const active = name === section;
- const badge = badgeParams && || ''
+ const badge = sectionBadge || (badgeParams && ) || ''
// If link points to another component, adds the prefix
link = link.startsWith('/') ? prefix + link : link;
return (
diff --git a/src/components/Sidebar/PlanUsageBadge.react.js b/src/components/Sidebar/PlanUsageBadge.react.js
new file mode 100644
index 0000000000..d24ffea607
--- /dev/null
+++ b/src/components/Sidebar/PlanUsageBadge.react.js
@@ -0,0 +1,32 @@
+import React, { useContext, useEffect, useState } from 'react';
+import { CurrentApp } from 'context/currentApp';
+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.
+const PlanUsageBadge = () => {
+ const context = useContext(CurrentApp);
+ const [badge, setBadge] = useState(null);
+
+ useEffect(() => {
+ let active = true;
+ if (context && context.getAppPlanData) {
+ getCachedPlanData(context)
+ .then(planData => active && setBadge(getUsageBadge(planData)))
+ .catch(() => {});
+ }
+ return () => {
+ active = false;
+ };
+ }, [context && context.applicationId]);
+
+ if (!badge) {
+ return null;
+ }
+ return {badge.percent}% ;
+};
+
+export default PlanUsageBadge;
diff --git a/src/components/Sidebar/PlanUsageBadge.scss b/src/components/Sidebar/PlanUsageBadge.scss
new file mode 100644
index 0000000000..6b6bcb1488
--- /dev/null
+++ b/src/components/Sidebar/PlanUsageBadge.scss
@@ -0,0 +1,35 @@
+// Same placement trick as the sidebar's Connect action: float for the plain row
+// (display: block), margin-left: auto for the active one (display: flex).
+// inline-block keeps the row's hover underline off the pill.
+.badge {
+ float: right;
+ display: inline-block;
+ flex-shrink: 0;
+ margin-left: auto;
+ margin-top: -1px;
+ text-decoration: none;
+ padding: 1px 8px;
+ border: 1px solid;
+ border-radius: 999px;
+ font-size: 12px;
+ font-weight: 600;
+ line-height: 18px;
+}
+
+.warning {
+ border-color: rgba(251, 255, 59, 0.5);
+ background: rgba(251, 255, 59, 0.08);
+ 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);
+ color: #E85C3E;
+}
diff --git a/src/components/UpgradeCheckout/BackupUpsell.react.js b/src/components/UpgradeCheckout/BackupUpsell.react.js
new file mode 100644
index 0000000000..86d1be64c1
--- /dev/null
+++ b/src/components/UpgradeCheckout/BackupUpsell.react.js
@@ -0,0 +1,48 @@
+import React, { useContext, useEffect, useState } from 'react';
+import Field from 'components/Field/Field.react';
+import Label from 'components/Label/Label.react';
+import { CurrentApp } from 'context/currentApp';
+import { UpgradeGateButton } from 'components/UpgradeCheckout/UpgradeCheckout.react';
+
+// Inside a delete confirmation: Free apps have no backups, so tell the user before
+// they confirm and offer MVP's daily backups. Renders nothing on paid plans.
+// Same row layout as the dialog's "Confirm this action" field, with the green upgrade button.
+const BackupUpsell = ({ gate }) => {
+ const context = useContext(CurrentApp);
+ const [isFree, setIsFree] = useState(false);
+
+ useEffect(() => {
+ let active = true;
+ if (context && context.getAppPlanData) {
+ context
+ .getAppPlanData()
+ .then(plan => active && setIsFree(/^free/i.test((plan && plan.planName) || '')))
+ .catch(() => {});
+ }
+ return () => {
+ active = false;
+ };
+ }, []);
+
+ if (!isFree) {
+ return null;
+ }
+ return (
+
+ }
+ input={
+
Plan Usage
Track your resource utilization across all features to optimize your app and plan allocation.
+
+
+
{planData.planName}
@@ -450,16 +325,16 @@ class AppPlan extends DashboardView {
-
Go live with your own domain, backups and a modern database
+
Go live with web hosting, backups and a modern database
WHAT YOU GET:
- MVP: Web hosting & custom domain, daily backups, latest MongoDB, email templates and collaborators
+ MVP: Web hosting, daily backups, latest MongoDB, email templates and collaborators
- Pay-as-you-Go: SOC 2 and ISO 27001-certified infrastructure
+ Pay-as-you-Go: Custom domain with HTTPS, SOC 2 and ISO 27001-certified infrastructure
Dedicated: Point-in-time restore & HIPAA-ready
@@ -490,7 +365,7 @@ class AppPlan extends DashboardView {
{prices.map((plan, idx) => (
-
+
))}
@@ -542,14 +417,23 @@ class AppPlan extends DashboardView {
export default AppPlan;
-const PriceCard = ({ plan, active, cycle, onClick }) => {
+const isFreePlan = planData => !!planData && !(planData instanceof Error) && /^free/i.test(planData.planName || '');
+
+const PriceCard = ({ plan, active, isFree, cycle, onClick }) => {
const { name, pricePerMonth, pricePerYear, details, greenText } = plan;
const price = cycle === 0 ? pricePerMonth : pricePerYear;
+ // Free sees MVP as its recommendation; everyone else sees Pay As You Go as the most popular.
+ let ribbon = null;
+ if (isFree && plan.id === 0) {
+ ribbon = 'Recommended for you';
+ } else if (!isFree && plan.id === 1) {
+ ribbon = 'Most Popular';
+ }
return (
- {plan.id === 1 ?
Most Popular : null}
+ {ribbon ?
{ribbon} : null}
{name} {greenText}
${price}
diff --git a/src/dashboard/AppPlan/AppPlan.scss b/src/dashboard/AppPlan/AppPlan.scss
index 2037db5a01..b9bc78b073 100644
--- a/src/dashboard/AppPlan/AppPlan.scss
+++ b/src/dashboard/AppPlan/AppPlan.scss
@@ -33,6 +33,10 @@
font-weight: 400;
line-height: 140%;
}
+ // Room for the usage banner between the intro and the plan card.
+ .usageBanner {
+ margin-top: 1.5rem;
+ }
.planUsage {
margin-top: 2.5rem;
gap: 1rem;
diff --git a/src/dashboard/AppPlan/prices.js b/src/dashboard/AppPlan/prices.js
new file mode 100644
index 0000000000..c32db452da
--- /dev/null
+++ b/src/dashboard/AppPlan/prices.js
@@ -0,0 +1,141 @@
+// BaaS plans shown on Plan Usage and sold by the direct checkout.
+export const prices = [
+ {
+ id: 0,
+ name: 'MVP',
+ desc: 'Validate Ideas Quickly โ Launch Fast on Our Managed Serverless Backend',
+ pricePerMonth: '25',
+ monthlyPlanId: 'gXGhzlMHZ6',
+ monthlyProductId: 'pri_01jjyr3kxmsav875y1v2p8h68k',
+ pricePerYear: '15',
+ annuallyPlanId: 'nUAySI815X',
+ annuallyProductId: 'pri_01jjyr5r3ayqcs5cr58bm5b4rw',
+ priceTag: 'Per App / Month',
+ savePercent: '40%',
+ details: [
+ {
+ text: 'Web hosting on a b4a.app subdomain',
+ },
+ {
+ text: 'Daily Backups',
+ },
+ {
+ text: 'Latest MongoDB',
+ },
+ {
+ text: 'Custom email templates',
+ },
+ {
+ number: 'Up to 3',
+ text: 'collaborators',
+ },
+ {
+ number: '500 K',
+ text: 'Requests',
+ },
+ {
+ number: '1 GB',
+ text: 'Data Storage',
+ },
+ {
+ number: '250 GB',
+ text: 'Data Transfer',
+ },
+ {
+ number: '50 GB',
+ text: 'File Storage',
+ },
+ ],
+ icon: 'b4a-mvp-plan-icon',
+ greenText: 'Go live with web hosting'
+ },
+ {
+ id: 1,
+ name: 'Pay As You Go',
+ desc: 'Run & Scale Applications on a Serverless Infrastructure',
+ pricePerMonth: '100',
+ monthlyPlanId: '7xWmyzNUvZ',
+ monthlyProductId: 'pri_01jjyr4fs9j1926g5tv54jvs0h',
+ pricePerYear: '80',
+ annuallyPlanId: 'YfX9ryk4UH',
+ annuallyProductId: 'pri_01jjyr4zj8dzg88xf82mtrnk6k',
+ priceTag: 'Per App / Month',
+ savePercent: '20%',
+ details: [
+ {
+ number: '5 M',
+ text: 'Requests',
+ },
+ {
+ number: '3 GB',
+ text: 'Data Storage',
+ },
+ {
+ number: '1 TB',
+ text: 'Data Transfer',
+ },
+ {
+ number: '250 GB',
+ text: 'File Storage',
+ },
+ {
+ text: 'Daily Backups',
+ },
+ {
+ text: 'Custom domain',
+ },
+ {
+ text: 'HTTPS on your custom domain',
+ },
+ {
+ text: 'SOC 2 and ISO 27001',
+ },
+ ],
+ icon: 'b4a-pay-as-you-go-plan-icon',
+ greenText: '200x more requests'
+ },
+ {
+ id: 2,
+ name: 'Dedicated',
+ desc: 'Production-Grade Speed, Isolation & Flexibility on Dedicated Resources',
+ pricePerMonth: '500',
+ monthlyPlanId: 'VGaDTCDNbi',
+ pricePerYear: '400',
+ monthlyProductId: 'pri_01jjyrqff5wb1pkcekge3ddrtz',
+ annuallyPlanId: 'U8nRA9rxdD',
+ annuallyProductId: 'pri_01jjyrsbcexqvqzsjvby0ykw2h',
+ priceTag: 'Per App / Month',
+ savePercent: '20%',
+ details: [
+ {
+ text: 'Unlimited Requests',
+ },
+ {
+ number: '10 CPUs / 14 GB',
+ },
+ {
+ number: '8 GB',
+ text: 'Data Storage',
+ },
+ {
+ number: '2 TB',
+ text: 'Data Transfer',
+ },
+ {
+ number: '1 TB',
+ text: 'File Storage',
+ },
+ {
+ text: 'Point-in-Time Backups',
+ },
+ {
+ text: 'SOC 2 and ISO 27001',
+ },
+ {
+ text: 'HIPAA After BAA Signed',
+ },
+ ],
+ icon: 'b4a-dedicated-plan-icon',
+ greenText: 'Unlimited requests'
+ },
+];
diff --git a/src/dashboard/AppPlan/usageClassUtils.js b/src/dashboard/AppPlan/usageClassUtils.js
index 25a404c9a3..c3a28bd381 100644
--- a/src/dashboard/AppPlan/usageClassUtils.js
+++ b/src/dashboard/AppPlan/usageClassUtils.js
@@ -1,9 +1,10 @@
// Utility to determine usage className based on usage and limit
// Returns 'usageDanger', 'usageWarning', or ''
-export function getUsageClassName(usage, limit) {
+// Percentage of `limit` used, understanding values like "21 K", "820 MB" or "0.25 GB". null if unknown.
+export function getUsagePercent(usage, limit) {
if (!usage || !limit) {
- return '';
+ return null;
}
const convertToNumber = (value) => {
@@ -34,9 +35,16 @@ export function getUsageClassName(usage, limit) {
const usageNum = convertToNumber(usage);
const limitNum = convertToNumber(limit);
if (!limitNum || isNaN(usageNum) || isNaN(limitNum)) {
+ return null;
+ }
+ return (usageNum / limitNum) * 100;
+}
+
+export function getUsageClassName(usage, limit) {
+ const percentage = getUsagePercent(usage, limit);
+ if (percentage === null) {
return '';
}
- const percentage = (usageNum / limitNum) * 100;
if (percentage > 90) {
return 'usageDanger';
}
diff --git a/src/dashboard/DashboardView.react.js b/src/dashboard/DashboardView.react.js
index 5a7b3004cb..ae241706d7 100644
--- a/src/dashboard/DashboardView.react.js
+++ b/src/dashboard/DashboardView.react.js
@@ -18,6 +18,7 @@ import { post } from 'lib/AJAX';
import B4aLoader from 'components/B4aLoader/B4aLoader.react';
import { canAccess } from 'lib/serverInfo';
import { openConnect } from 'dashboard/Data/AppOverview/connectEvents';
+import PlanUsageBadge from 'components/Sidebar/PlanUsageBadge.react';
import { PlugIcon } from 'dashboard/Data/AppOverview/connectIcons.react';
// Alert parameters
@@ -426,6 +427,7 @@ export default class DashboardView extends React.Component {
name: 'Plan Usage',
icon: 'b4a-plan-usage-icon',
link: '/plan-usage',
+ badge:
,
})
appSidebarSections.push({
diff --git a/src/dashboard/Data/AppOverview/AppOverview.react.js b/src/dashboard/Data/AppOverview/AppOverview.react.js
index 4f8b0d6c02..1b2bae7331 100644
--- a/src/dashboard/Data/AppOverview/AppOverview.react.js
+++ b/src/dashboard/Data/AppOverview/AppOverview.react.js
@@ -20,7 +20,9 @@ import B4aTooltip from 'components/Tooltip/B4aTooltip.react';
import OnboardingBoxes from './OnboardingBoxes.react';
import AccountManager from 'lib/AccountManager';
import { amplitudeLogEvent } from 'lib/amplitudeEvents';
-import { UpgradeGate } from 'lib/upgradeEvents';
+import { UpgradeGate, UpgradeGateView, logGateClicked, planUsagePath } from 'lib/upgradeEvents';
+import UsageLimitBanner from 'components/UsageLimitBanner/UsageLimitBanner.react';
+import RegionChange from './RegionChange.react';
import { UpgradeGateButton } from 'components/UpgradeCheckout/UpgradeCheckout.react';
import AppOverviewActions from './AppOverviewActions.react';
import ComplianceCard from './ComplianceCard.react';
@@ -265,6 +267,7 @@ class AppOverview extends DashboardView {
renderContent() {
const { isLoadingAppPlanData, appPlanData } = this.state;
// Same rule as before: the version shows on Free plans or once on MongoDB 8.0.
+ const isFreeApp = !isLoadingAppPlanData && !(appPlanData instanceof Error) && /^free/i.test(appPlanData?.planName || '');
const showDatabaseVersion = (!isLoadingAppPlanData && !(appPlanData instanceof Error) && /Free/i.test(appPlanData.planName))
|| this.context.databaseVersion === '8.0';
const webhost = this.state.webhosting?.hostSettings?.webhost;
@@ -272,7 +275,7 @@ class AppOverview extends DashboardView {
const webUrl = customDomain || webhost;
// Same rule as the Domains paywall: the plan cannot use web hosting and nothing is configured yet.
const webUrlLocked = !webUrl && this.state.webhosting?.canChangeSubdomain === false;
- // MVP includes custom domains but not HTTPS on them; that starts on Pay As You Go.
+ // Custom domains and HTTPS start on Pay As You Go; an MVP app can still have a legacy custom domain.
const httpsLocked = !!customDomain && (this.state.appPlanData?.planName || '').indexOf('MVP') === 0;
const database = [this.context.databaseType, showDatabaseVersion && this.context.databaseVersion].filter(Boolean).join(' ');
return (
@@ -283,6 +286,12 @@ class AppOverview extends DashboardView {
+
+
{/* The app at a glance: name + App ID + Actions on top, then one row
of facts. The keys live in Get connected โ Keys. */}
@@ -323,7 +332,7 @@ class AppOverview extends DashboardView {
{this.context.serverURL}
amplitudeLogEvent('On Click - Change Hosting Region Button')} href={`https://back4app.typeform.com/to/kMjTovFj?appId=${this.context.applicationId}`} target="_blank" rel="noopener noreferrer">Change}
+ extra={ }
>
{this.context.region}
@@ -335,10 +344,15 @@ class AppOverview extends DashboardView {
renderTrigger={open =>
Enable web hosting }
/>
) : httpsLocked ? (
-
Enable HTTPS }
- />
+ // Already paying for MVP: compare plans instead of opening a second subscription.
+ logGateClicked(UpgradeGate.HTTPS, this.context.applicationId)}
+ >
+
+ Enable HTTPS
+
) : (
{!webUrl ? 'Enable web hosting' : customDomain ? 'Manage' : 'Use your domain'}
diff --git a/src/dashboard/Data/AppOverview/RegionChange.react.js b/src/dashboard/Data/AppOverview/RegionChange.react.js
new file mode 100644
index 0000000000..fff8a60da1
--- /dev/null
+++ b/src/dashboard/Data/AppOverview/RegionChange.react.js
@@ -0,0 +1,87 @@
+import React, { useContext, useState } from 'react';
+import B4aModal from 'components/B4aModal/B4aModal.react';
+import { CurrentApp } from 'context/currentApp';
+import { amplitudeLogEvent } from 'lib/amplitudeEvents';
+import { UpgradeGate, UpgradeGateView, logGateClicked, regionChangeFormUrl } from 'lib/upgradeEvents';
+import { UpgradeCheckoutModal } from 'components/UpgradeCheckout/UpgradeCheckout.react';
+import styles from 'dashboard/Data/AppOverview/RegionChange.scss';
+
+// Regions the migration form offers. Europe is available on Free; the rest need a paid plan.
+const REGIONS = [
+ { name: 'Europe', flag: '๐ช๐บ', free: true },
+ { name: 'South Korea', flag: '๐ฐ๐ท' },
+ { name: 'India', flag: '๐ฎ๐ณ' },
+ { name: 'Australia', flag: '๐ฆ๐บ' },
+ { name: 'Singapore', flag: '๐ธ๐ฌ' },
+];
+
+// "Change" under Hosting Region. Paid apps go straight to the migration form, as before.
+// On Free, a paid region opens the MVP checkout first; Europe goes to the form.
+const RegionChange = ({ isFree, className }) => {
+ const context = useContext(CurrentApp);
+ const [step, setStep] = useState(null); // null | 'pick' | 'checkout'
+ const formUrl = regionChangeFormUrl(context.applicationId);
+
+ if (!isFree) {
+ return (
+ amplitudeLogEvent('On Click - Change Hosting Region Button')}
+ href={formUrl}
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ Change
+
+ );
+ }
+
+ const choose = region => {
+ if (region.free) {
+ window.open(formUrl, '_blank', 'noopener,noreferrer');
+ setStep(null);
+ return;
+ }
+ logGateClicked(UpgradeGate.REGION_CHANGE, context.applicationId);
+ setStep('checkout');
+ };
+
+ return (
+ <>
+ {
+ event.preventDefault();
+ amplitudeLogEvent('On Click - Change Hosting Region Button');
+ setStep('pick');
+ }}
+ >
+ Change
+
+ {step === 'pick' ? (
+ setStep(null)}
+ customFooter={
}
+ >
+
+
+ {REGIONS.map(region => (
+ choose(region)}>
+ {region.flag}
+ {region.name}
+ {region.free ? 'Free' : 'Paid plans'}
+
+ ))}
+
+
+ ) : null}
+ {step === 'checkout' ? setStep(null)} /> : null}
+ >
+ );
+};
+
+export default RegionChange;
diff --git a/src/dashboard/Data/AppOverview/RegionChange.scss b/src/dashboard/Data/AppOverview/RegionChange.scss
new file mode 100644
index 0000000000..4937aa8b4c
--- /dev/null
+++ b/src/dashboard/Data/AppOverview/RegionChange.scss
@@ -0,0 +1,55 @@
+@import 'stylesheets/globals.scss';
+@import 'stylesheets/back4app.scss';
+
+.regions {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 0 0.5rem;
+}
+
+.region {
+ @include InterFont;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ width: 100%;
+ padding: 12px 16px;
+ border: 1px solid #e2e6ec;
+ border-radius: 8px;
+ background: #ffffff;
+ color: #10203a;
+ font-size: 15px;
+ text-align: left;
+ cursor: pointer;
+
+ &:hover {
+ border-color: $cta-green;
+ }
+}
+
+.flag {
+ font-size: 18px;
+}
+
+.name {
+ flex: 1;
+}
+
+.free,
+.paid {
+ padding: 2px 8px;
+ border-radius: 999px;
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.free {
+ background: rgba(39, 174, 96, 0.12);
+ color: $cta-green;
+}
+
+.paid {
+ background: #f0f2f5;
+ color: #5b6577;
+}
diff --git a/src/dashboard/Data/AppOverview/usageAlert.js b/src/dashboard/Data/AppOverview/usageAlert.js
new file mode 100644
index 0000000000..751b0e468f
--- /dev/null
+++ b/src/dashboard/Data/AppOverview/usageAlert.js
@@ -0,0 +1,204 @@
+import { getUsagePercent } from '../../AppPlan/usageClassUtils';
+
+// Same thresholds as the usage bars: yellow above 70%, red above 90%.
+const WARNING_PERCENT = 70;
+const DANGER_PERCENT = 90;
+
+// Over-quota prices from the pricing page FAQ. Requests depend on the plan. Only the current
+// MVP and Pay As You Go publish them, and Advanced has the same limits and prices as
+// Pay As You Go: the other legacy plans get the warning without a price.
+const EXTRA_REQUESTS_PRICE = { MVP: '$5', 'Pay As You Go': '$2', Advanced: '$2' };
+
+// The next plan up, by kind. Legacy plans follow the current plan of their size.
+const UPGRADE_TARGET = { free: 'MVP', starter: 'MVP', mvp: 'Pay As You Go', payg: 'Dedicated' };
+
+// Listed by priority when two limits are equally urgent: API requests run out first,
+// then the database, then file storage.
+const resourcesOf = planData => [
+ {
+ label: 'API requests',
+ monthly: true,
+ used: planData.apiCallUsed || planData.apiCallUsedNormalized,
+ limit: planData.apiCallLimit,
+ overage: plan => `extra requests are billed at ${EXTRA_REQUESTS_PRICE[plan]} per 100K`,
+ },
+ {
+ label: 'database storage',
+ used: planData.dataStorageUsed || planData.dataStorageUsedNormalized,
+ limit: planData.dataStorageLimit,
+ overage: () => 'extra database storage is billed at $15 per GB',
+ },
+ {
+ label: 'file storage',
+ used: planData.fileStorageUsed || planData.fileStorageUsedNormalized,
+ limit: planData.fileStorageLimit,
+ overage: () => 'extra file storage is billed at $1 per 10 GB',
+ },
+];
+
+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.
+// 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.
+export const planKindOf = planData => {
+ const name = planNameOf(planData);
+ if (/^free/i.test(name)) {
+ return 'free';
+ }
+ if (/^(solo|starter)/i.test(name)) {
+ return 'starter';
+ }
+ if (/^(mvp|basic|intermediate|standard)/i.test(name)) {
+ return 'mvp';
+ }
+ if (/^advanced|pay\s*as\s*you\s*go/i.test(name)) {
+ return 'payg';
+ }
+ return 'other';
+};
+
+// How the plan is called in a message: "MVP", "Starter", "Pay As You Go".
+const planLabelOf = planData => {
+ const name = planNameOf(planData).replace(/ Plan\b.*$/i, '').trim();
+ return /pay\s*as\s*you\s*go/i.test(name) ? 'Pay As You Go' : name;
+};
+
+// " Above 100%, extra requests are billed at ..." when the plan publishes its overage price.
+const overageOf = (worst, plan, lead) => (plan in EXTRA_REQUESTS_PRICE ? ` ${lead}${worst.overage(plan)}.` : '');
+
+// The limit to talk about: most urgent first (over 100%, then red, then yellow) and, within
+// the same level, the list order above. Also returns the others, most urgent first.
+// Null when there is no usage data.
+export const getMostUrgentUsage = planData => {
+ if (!planData || planData instanceof Error) {
+ return null;
+ }
+ const resources = resourcesOf(planData)
+ .map((resource, priority) => {
+ const percent = getUsagePercent(resource.used, resource.limit);
+ return { ...resource, priority, percent, severity: severityOf(percent) };
+ })
+ .filter(resource => resource.percent !== null)
+ .sort((a, b) => b.severity - a.severity || a.priority - b.priority);
+ return resources.length ? { worst: resources[0], others: resources.slice(1) } : null;
+};
+
+// 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.
+// 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) => {
+ const kind = planKindOf(planData);
+ 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';
+
+ 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,
+ };
+ }
+ if (!worst) {
+ return null;
+ }
+
+ 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',
+ message: `Your app reached its ${worst.label} limit and can stop responding at any moment. Upgrade now to keep it running.`,
+ action: 'checkout',
+ actionLabel: 'Upgrade to keep it running',
+ };
+ }
+ return {
+ level: worst.percent > DANGER_PERCENT ? 'danger' : 'warning',
+ 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',
+ };
+ }
+
+ 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;
+};
+
+// The sidebar pill next to Plan Usage, following the same rules as the banner.
+// Null when the banner would say nothing (blocked apps aside).
+export const getUsageBadge = planData => {
+ const kind = planKindOf(planData);
+ const usage = getMostUrgentUsage(planData);
+ const worst = usage && usage.worst;
+ if (!worst) {
+ 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;
+};
+
+const usageSummary = (worst, percent, amount, onPlan) =>
+ worst.monthly
+ ? `You've used ${percent}% of this month's ${worst.label}${onPlan} ${amount}.`
+ : `Your ${worst.label} is ${percent}% full${onPlan} ${amount}.`;
+
+const othersClose = usage => {
+ const count = usage.others.filter(resource => resource.percent > WARNING_PERCENT).length;
+ return count ? ` ${count} other ${count === 1 ? 'limit is' : 'limits are'} close too.` : '';
+};
+
+const severityOf = percent => {
+ if (percent >= 100) {
+ return 3;
+ }
+ if (percent > DANGER_PERCENT) {
+ return 2;
+ }
+ return percent > WARNING_PERCENT ? 1 : 0;
+};
+
+const blockedMessage = worst =>
+ `Your app stopped responding: it reached its ${worst ? worst.label : 'plan'} limit. Your app's users are getting errors right now.`;
diff --git a/src/dashboard/Data/Browser/ConfirmDeleteColumnDialog.react.js b/src/dashboard/Data/Browser/ConfirmDeleteColumnDialog.react.js
index d04df0672b..c8e906f22a 100644
--- a/src/dashboard/Data/Browser/ConfirmDeleteColumnDialog.react.js
+++ b/src/dashboard/Data/Browser/ConfirmDeleteColumnDialog.react.js
@@ -7,11 +7,13 @@
*/
import Modal from 'components/Modal/Modal.react';
import React from 'react';
+import BackupUpsell from 'components/UpgradeCheckout/BackupUpsell.react';
+import { UpgradeGate } from 'lib/upgradeEvents';
export default class ConfirmDeleteColumnDialog extends React.Component {
render() {
- let content = null;
+ const content = ;
return (
+ {this.props.relation ? null : }
{content}
);
diff --git a/src/dashboard/Data/Browser/DropClassDialog.react.js b/src/dashboard/Data/Browser/DropClassDialog.react.js
index 11e866c926..8ef448b471 100644
--- a/src/dashboard/Data/Browser/DropClassDialog.react.js
+++ b/src/dashboard/Data/Browser/DropClassDialog.react.js
@@ -10,6 +10,8 @@ import Label from 'components/Label/Label.react';
import B4aModal from 'components/B4aModal/B4aModal.react';
import React from 'react';
import TextInput from 'components/TextInput/TextInput.react';
+import BackupUpsell from 'components/UpgradeCheckout/BackupUpsell.react';
+import { UpgradeGate } from 'lib/upgradeEvents';
export default class DropClassDialog extends React.Component {
constructor() {
@@ -46,6 +48,7 @@ export default class DropClassDialog extends React.Component {
onClose={this.props.onCancel}
onConfirm={this.props.onConfirm}
>
+
0;
if (hasColumns) {
- content = (
+ content = [
+ ,
}
input={
}
- />
- );
+ />,
+ ];
}
return (
{
- this.setState({ maxJobAmount: (planData && planData.maxJobAmount) || null });
+ this.setState({
+ maxJobAmount: (planData && planData.maxJobAmount) || null,
+ isFreePlan: /^free/i.test((planData && planData.planName) || ''),
+ });
})
.catch(() => {
this.setState({ maxJobAmount: null });
@@ -422,7 +429,11 @@ class Jobs extends TableView {
}
renderExtras() {
- const { toDelete, deleteInProgress, deleteError, toEdit, toCreate, jobLimitReached } = this.state;
+ const { toDelete, deleteInProgress, deleteError, toEdit, toCreate, jobLimitReached, isFreePlan, jobsCheckoutOpen } = this.state;
+
+ if (jobsCheckoutOpen) {
+ return this.setState({ jobsCheckoutOpen: false })} />;
+ }
if (jobLimitReached) {
return (
@@ -436,6 +447,10 @@ class Jobs extends TableView {
onCancel={() => this.setState({ jobLimitReached: false })}
onConfirm={() => {
logGateClicked(UpgradeGate.JOBS, this.context.applicationId);
+ if (isFreePlan) {
+ this.setState({ jobLimitReached: false, jobsCheckoutOpen: true });
+ return;
+ }
this.props.navigate(generatePath(this.context, `plan-usage?gate=${UpgradeGate.JOBS}`));
}}
>
diff --git a/src/dashboard/Data/Logs/AccessLogs.react.js b/src/dashboard/Data/Logs/AccessLogs.react.js
index 838baf73ec..f601d730b6 100644
--- a/src/dashboard/Data/Logs/AccessLogs.react.js
+++ b/src/dashboard/Data/Logs/AccessLogs.react.js
@@ -18,6 +18,7 @@ import ServerLogsView from 'components/ServerLogsView/ServerLogsView.react';
import { withRouter } from 'lib/withRouter';
import styles from 'dashboard/Data/Logs/Logs.scss';
+import LogsFooter from 'dashboard/Data/Logs/LogsFooter.react';
const alertWhatIsMessage = (
@@ -171,6 +172,7 @@ export default class AccessLogs extends DashboardView {
{alertWhatIs}
+
)}
diff --git a/src/dashboard/Data/Logs/ErrorLogs.react.js b/src/dashboard/Data/Logs/ErrorLogs.react.js
index 318910716b..2ce6fbdc8a 100644
--- a/src/dashboard/Data/Logs/ErrorLogs.react.js
+++ b/src/dashboard/Data/Logs/ErrorLogs.react.js
@@ -19,6 +19,7 @@ import Icon from 'components/Icon/Icon.react';
import { withRouter } from 'lib/withRouter';
import styles from 'dashboard/Data/Logs/Logs.scss';
+import LogsFooter from 'dashboard/Data/Logs/LogsFooter.react';
const subsections = {
access: 'Access',
@@ -188,6 +189,7 @@ export default class InfoLogs extends DashboardView {
/>
))}
+
)}
diff --git a/src/dashboard/Data/Logs/InfoLogs.react.js b/src/dashboard/Data/Logs/InfoLogs.react.js
index 54f60735ec..2428b4224e 100644
--- a/src/dashboard/Data/Logs/InfoLogs.react.js
+++ b/src/dashboard/Data/Logs/InfoLogs.react.js
@@ -19,6 +19,7 @@ import Icon from 'components/Icon/Icon.react';
import { withRouter } from 'lib/withRouter';
import styles from 'dashboard/Data/Logs/Logs.scss';
+import LogsFooter from 'dashboard/Data/Logs/LogsFooter.react';
const subsections = {
access: 'Access',
@@ -185,6 +186,7 @@ export default class InfoLogs extends DashboardView {
text={message}
timestamp={timestamp} />)}
+
)}
diff --git a/src/dashboard/Data/Logs/Logs.scss b/src/dashboard/Data/Logs/Logs.scss
index 749cf58c17..230f43a2f3 100644
--- a/src/dashboard/Data/Logs/Logs.scss
+++ b/src/dashboard/Data/Logs/Logs.scss
@@ -54,4 +54,26 @@
&:hover svg {
fill: #9a99a5;
}
-}
\ No newline at end of file
+}
+// End of the log list: older logs come through support; Free also sees the retention upsell.
+.logsFooter {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px 12px;
+ margin-top: 1rem;
+ padding: 12px 16px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 8px;
+ color: rgba(255, 255, 255, 0.7);
+ font-size: 14px;
+ line-height: 1.4;
+
+ a {
+ color: #27AE60;
+ font-weight: 600;
+ }
+}
+
+.logsFooterUpsell {
+ color: $white;
+}
diff --git a/src/dashboard/Data/Logs/LogsFooter.react.js b/src/dashboard/Data/Logs/LogsFooter.react.js
new file mode 100644
index 0000000000..8e0da07adc
--- /dev/null
+++ b/src/dashboard/Data/Logs/LogsFooter.react.js
@@ -0,0 +1,48 @@
+import React, { useContext, useEffect, useState } from 'react';
+import { CurrentApp } from 'context/currentApp';
+import { UpgradeGate, SUPPORT_TICKET_URL } from 'lib/upgradeEvents';
+import { UpgradeGateButton } from 'components/UpgradeCheckout/UpgradeCheckout.react';
+import styles from 'dashboard/Data/Logs/Logs.scss';
+
+// End of a log list: only the most recent lines are shown and older ones come through
+// support. On Access logs, Free also learns that MVP keeps a week instead of a day
+// (the retention published on the pricing page).
+const LogsFooter = ({ showRetention = false }) => {
+ const context = useContext(CurrentApp);
+ const [isFree, setIsFree] = useState(false);
+
+ useEffect(() => {
+ let active = true;
+ if (showRetention && context && context.getAppPlanData) {
+ context
+ .getAppPlanData()
+ .then(plan => active && setIsFree(/^free/i.test((plan && plan.planName) || '')))
+ .catch(() => {});
+ }
+ return () => {
+ active = false;
+ };
+ }, [showRetention]);
+
+ return (
+
+
+ These are the most recent logs. Need older ones?{' '}
+ Open a ticket .
+
+ {showRetention && isFree ? (
+
+ Free keeps access logs for 1 day; MVP keeps 7 days.{' '}
+ (
+ Upgrade to MVP
+ )}
+ />
+
+ ) : null}
+
+ );
+};
+
+export default LogsFooter;
diff --git a/src/dashboard/Data/Logs/SystemLogs.react.js b/src/dashboard/Data/Logs/SystemLogs.react.js
index 0eed97719f..8e798a307e 100644
--- a/src/dashboard/Data/Logs/SystemLogs.react.js
+++ b/src/dashboard/Data/Logs/SystemLogs.react.js
@@ -18,6 +18,7 @@ import ServerLogsView from 'components/ServerLogsView/ServerLogsView.react';
import { withRouter } from 'lib/withRouter';
import styles from 'dashboard/Data/Logs/Logs.scss';
+import LogsFooter from 'dashboard/Data/Logs/LogsFooter.react';
const alertWhatIsMessage = (
@@ -181,6 +182,7 @@ export default class SystemLogs extends DashboardView {
{alertWhatIs}
+
)}
diff --git a/src/dashboard/DatabaseProfiler/DatabaseProfiler.react.js b/src/dashboard/DatabaseProfiler/DatabaseProfiler.react.js
index b6e76b90dd..2dc9ba61a5 100644
--- a/src/dashboard/DatabaseProfiler/DatabaseProfiler.react.js
+++ b/src/dashboard/DatabaseProfiler/DatabaseProfiler.react.js
@@ -16,13 +16,9 @@ import stylesTable from 'dashboard/TableView.scss';
import B4aLoaderContainer from 'components/B4aLoaderContainer/B4aLoaderContainer.react';
import Icon from 'components/Icon/Icon.react';
import DatabaseProfilerDetail from './DatabaseProfilerDetail.react';
-import { prices } from 'dashboard/AppPlan/AppPlan.react';
-import Button from 'components/Button/Button.react';
-import B4aModal from 'components/B4aModal/B4aModal.react';
-import { initializePaddle } from '@paddle/paddle-js';
-import { amplitudeLogEvent } from 'lib/amplitudeEvents';
-import { UpgradeEvent, UpgradeGate, UpgradeGateView, logGateClicked } from 'lib/upgradeEvents';
-import AccountManager from 'lib/AccountManager';
+import { prices } from 'dashboard/AppPlan/prices';
+import { UpgradeGate } from 'lib/upgradeEvents';
+import { UpgradeGateButton } from 'components/UpgradeCheckout/UpgradeCheckout.react';
@withRouter
class DatabaseProfile extends DashboardView {
@@ -36,8 +32,6 @@ class DatabaseProfile extends DashboardView {
databaseProfilerError: null,
selectedRowId: null,
showBackButton: false,
- paddle: null,
- openCheckout: false,
applicationId: null,
};
}
@@ -47,8 +41,6 @@ class DatabaseProfile extends DashboardView {
isLoadingDatabaseProfiler: true,
});
this.loadData();
- this.loadPaddle();
- this.getAppOwnerEmail();
}
componentWillReceiveProps(nextProps, nextContext) {
@@ -95,88 +87,6 @@ class DatabaseProfile extends DashboardView {
}
}
- async getAppOwnerEmail() {
- let appOwnerEmail;
- if (!this.context.custom.isOwner) {
- const { ownerEmail } = await this.context.getAppOwnerEmail();
- appOwnerEmail = ownerEmail;
- } else {
- appOwnerEmail = AccountManager.currentUser().email;
- }
- this.setState({ appOwnerEmail });
- }
-
- loadPaddle() {
- const paddleOptions = {
- token: b4aSettings.PADDLE_TOKEN || 'test_0270ab179b4f4abd7aa228c7014',
- environment: process.env.SENTRY_ENV === 'production' ? 'production' : 'sandbox',
- pwCustomer: {}
- };
-
- const paddleEventCallback = async (data) => {
- if (data.name === 'checkout.completed') {
- this.checkoutCompleted = true;
- const paymentData = {
- appId: data.data.custom_data.app_id,
- customerId: data.data.customer.id,
- planName: data.data.items[0].product.name,
- transactionId: data.data.transaction_id,
- checkoutId: data.data.id,
- results: data.data,
- planId: data.data.custom_data.plan_id,
- email: data.data.customer.email,
- };
-
- try {
- await fetch(`${b4aSettings.BACK4APP_CHECKOUT_URL}/save-subscription`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(paymentData),
- });
- } catch (e) { }
-
- try {
- const amplitudePayload = {
- api_key: b4aSettings.BACK4APP_AMPLITUDE_KEY,
- events: [
- {
- user_id: paymentData.email || 'unknown',
- event_type: 'At Checkout - Subscription Successful',
- time: Date.now(),
- event_properties: {
- appId: paymentData.appId,
- planName: paymentData.planName,
- planType: data.data.items[0].billing_cycle.interval,
- subscriptionTotal: data.data.totals.total,
- source: 'gate',
- gate: UpgradeGate.DB_PROFILER,
- }
- }
- ]
- };
- await fetch('https://api.amplitude.com/2/httpapi', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(amplitudePayload)
- });
- } catch (error) { }
- }
- if (data.name === 'checkout.closed') {
- this.setState({ openCheckout: false });
- }
- };
-
- initializePaddle({ ...paddleOptions, eventCallback: paddleEventCallback }).then(
- (paddleInstance) => {
- if (paddleInstance) {
- this.setState({ paddle: paddleInstance });
- }
- },
- );
- }
-
handleBackClick = () => {
this.setState({
selectedRowId: null,
@@ -184,10 +94,6 @@ class DatabaseProfile extends DashboardView {
});
};
- componentWillUnmount() {
- this.setState({ openCheckout: false });
- }
-
renderToolbar() {
const { showBackButton } = this.state;
return (
@@ -234,44 +140,6 @@ class DatabaseProfile extends DashboardView {
);
}
- handleUpgradeClick = () => {
- // Find Dedicated plan
- const dedicatedPlan = prices.find(plan => plan.name === 'Dedicated');
- if (!dedicatedPlan) { return; }
- logGateClicked(UpgradeGate.DB_PROFILER, this.context.applicationId);
- this.checkoutContext = {
- app_id: this.context.applicationId,
- plan: dedicatedPlan.name,
- cycle: 'monthly',
- source: 'gate',
- gate: UpgradeGate.DB_PROFILER,
- };
- this.checkoutCompleted = false;
- amplitudeLogEvent(UpgradeEvent.CHECKOUT_OPENED, this.checkoutContext);
- this.setState({ openCheckout: true }, () => {
- const productId = dedicatedPlan.monthlyProductId;
- const planId = dedicatedPlan.monthlyPlanId;
- this.state.paddle?.Checkout.open({
- items: [{ priceId: process.env.SENTRY_ENV === 'production' ? productId : 'pri_01jjykwj65y5de1vcv5xaryw8g', quantity: 1 }],
- title: dedicatedPlan.name,
- settings: {
- displayMode: 'inline',
- theme: 'light',
- locale: 'en',
- variant: 'one-page',
- frameTarget: 'checkout-container',
- frameInitialHeight: '450',
- frameStyle: 'width: 100%; min-width: 312px; max-height: 80vh; background-color: #f9f9f9; border: none;'
- },
- customData: { appId: this.context.applicationId, planId },
- allowLogout: false,
- customer: {
- email: this.state.appOwnerEmail,
- }
- });
- });
- };
-
handleComparePlansClick = (applicationId) => {
window.location.href = `${b4aSettings.BACKEND_DASHBOARD_PATH}/apps/${applicationId}/plan-usage?gate=${UpgradeGate.DB_PROFILER}`;
};
@@ -286,6 +154,7 @@ class DatabaseProfile extends DashboardView {
/>
);
} else if (databaseProfilerError?.message === 'PLAN_NOT_SUPPORTED') {
+ const dedicatedPlan = prices.find(plan => plan.name === 'Dedicated');
return (
-
{/* Dedicated plan highlight card */}
-
$500
+
${dedicatedPlan.pricePerYear}
per App / Month
-
Billed Monthly
+
Billed yearly ยท ${dedicatedPlan.pricePerMonth} monthly
{/* Features list */}
@@ -346,7 +214,7 @@ class DatabaseProfile extends DashboardView {
{/* Choose plan to entire div with button */}
-
+
@@ -448,22 +316,6 @@ class DatabaseProfile extends DashboardView {
{toolbar}
-
- {this.state.openCheckout ? (
-
}
- onCancel={() => {
- if (!this.checkoutCompleted) {
- amplitudeLogEvent(UpgradeEvent.CHECKOUT_CLOSED, this.checkoutContext);
- }
- this.setState({ openCheckout: false });
- }}
- >
-
-
- ) : null}
);
}
diff --git a/src/dashboard/DomainSettings/DomainSettings.react.js b/src/dashboard/DomainSettings/DomainSettings.react.js
index a79a2079dd..aadc4ef224 100644
--- a/src/dashboard/DomainSettings/DomainSettings.react.js
+++ b/src/dashboard/DomainSettings/DomainSettings.react.js
@@ -22,7 +22,8 @@ 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 } from 'lib/upgradeEvents';
+import { UpgradeGate, UpgradeGateView, logGateClicked, planUsagePath } from 'lib/upgradeEvents';
+import { Link } from 'react-router-dom';
import { UpgradeGateButton } from 'components/UpgradeCheckout/UpgradeCheckout.react';
import B4aNotification from 'dashboard/Data/Browser/B4aNotification.react';
import browserStyles from 'dashboard/Data/Browser/Browser.scss';
@@ -406,7 +407,7 @@ class DomainSettings extends DashboardView {
!this.state.canChangeSubdomain && !this.state.isActivated && this.state.currentSubdomain.trim().length === 0 && this.state.customDomainArray.length === 0) {
content =
}
+ label={ }
input={
}
@@ -558,7 +559,14 @@ 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/planDataCache.js b/src/lib/planDataCache.js
new file mode 100644
index 0000000000..b1839f75c7
--- /dev/null
+++ b/src/lib/planDataCache.js
@@ -0,0 +1,22 @@
+// Plan usage for the sidebar badge. Every dashboard page renders its own sidebar, so
+// without this each navigation would refetch the plan; one request per app every 5 minutes.
+const TTL_MS = 5 * 60 * 1000;
+const cache = new Map();
+
+export const getCachedPlanData = app => {
+ const key = app.applicationId;
+ const hit = cache.get(key);
+ if (hit && Date.now() - hit.at < TTL_MS) {
+ return hit.promise;
+ }
+ const promise = Promise.resolve()
+ .then(() => app.getAppPlanData())
+ .catch(error => {
+ cache.delete(key);
+ throw error;
+ });
+ cache.set(key, { promise, at: Date.now() });
+ return promise;
+};
+
+export const clearPlanDataCache = () => cache.clear();
diff --git a/src/lib/tests/LogsFooter.test.js b/src/lib/tests/LogsFooter.test.js
new file mode 100644
index 0000000000..99872d9782
--- /dev/null
+++ b/src/lib/tests/LogsFooter.test.js
@@ -0,0 +1,51 @@
+// The test preprocessor does not hoist jest.mock, so mocked modules are required after it.
+// The preprocessor rewrites `lib/` and `components/` imports two levels up, which from
+// dashboard/Data/Logs lands under src/dashboard; map those paths to the real modules.
+jest.mock('../../dashboard/lib/upgradeEvents', () => jest.requireActual('../upgradeEvents'), { virtual: true });
+jest.mock('../amplitudeEvents', () => ({ amplitudeLogEvent: jest.fn() }));
+jest.mock(
+ '../../dashboard/components/UpgradeCheckout/UpgradeCheckout.react',
+ () => ({ UpgradeGateButton: ({ gate, renderTrigger }) => renderTrigger(() => gate) }),
+ { virtual: true }
+);
+jest.mock('context/currentApp', () => ({ CurrentApp: require('react').createContext(null) }), { virtual: true });
+jest.mock('dashboard/Data/Logs/Logs.scss', () => ({}), { virtual: true });
+
+const React = require('react');
+const renderer = require('react-test-renderer');
+const { CurrentApp } = require('context/currentApp');
+const LogsFooter = require('../../dashboard/Data/Logs/LogsFooter.react').default;
+
+const mount = async (planName, props) => {
+ let tree;
+ const app = { applicationId: 'app-1', getAppPlanData: jest.fn(() => Promise.resolve({ planName })) };
+ await renderer.act(async () => {
+ tree = renderer.create(
+
+
+
+ );
+ });
+ return { tree, app, text: JSON.stringify(tree.toJSON()) };
+};
+
+describe('LogsFooter', () => {
+ it('always points to support for older logs', async () => {
+ const { text } = await mount('MVP Plan', {});
+ expect(text).toContain('Need older ones?');
+ expect(text).toContain('https://help.back4app.com/hc/en-us/requests/new');
+ });
+
+ it('offers the week of access logs to Free on the Access screen', async () => {
+ const { text } = await mount('Free Plan', { showRetention: true });
+ expect(text).toContain('Free keeps access logs for 1 day; MVP keeps 7 days.');
+ expect(text).toContain('Upgrade to MVP');
+ });
+
+ it('does not upsell paid plans or the other log screens', async () => {
+ expect((await mount('MVP Plan', { showRetention: true })).text).not.toContain('Upgrade to MVP');
+ const other = await mount('Free Plan', {});
+ expect(other.text).not.toContain('Upgrade to MVP');
+ expect(other.app.getAppPlanData).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/lib/tests/PlanUsageBadge.test.js b/src/lib/tests/PlanUsageBadge.test.js
new file mode 100644
index 0000000000..4b18c69c42
--- /dev/null
+++ b/src/lib/tests/PlanUsageBadge.test.js
@@ -0,0 +1,100 @@
+// The test preprocessor does not hoist jest.mock, so mocked modules are required after it.
+jest.mock('context/currentApp', () => ({ CurrentApp: require('react').createContext(null) }), { virtual: true });
+jest.mock('dashboard/Data/AppOverview/usageAlert', () => jest.requireActual('../../dashboard/Data/AppOverview/usageAlert'), { virtual: true });
+jest.mock('components/Sidebar/PlanUsageBadge.scss', () => ({ badge: 'badge', warning: 'warning', danger: 'danger' }), { virtual: true });
+
+const React = require('react');
+const renderer = require('react-test-renderer');
+const { CurrentApp } = require('context/currentApp');
+const { getCachedPlanData, clearPlanDataCache } = require('../planDataCache');
+const PlanUsageBadge = require('../../components/Sidebar/PlanUsageBadge.react').default;
+
+const plan = overrides => ({
+ planName: 'Free Plan',
+ apiCallUsed: '2', apiCallLimit: '25 K',
+ fileStorageUsed: '0 KB', fileStorageLimit: '1 GB',
+ dataStorageUsed: '400 KB', dataStorageLimit: '0.25 GB',
+ ...overrides,
+});
+
+const mount = async planData => {
+ let tree;
+ const app = { applicationId: 'app-badge', getAppPlanData: jest.fn(() => Promise.resolve(planData)) };
+ await renderer.act(async () => {
+ tree = renderer.create(
+
+
+
+ );
+ });
+ return tree.toJSON();
+};
+
+describe('PlanUsageBadge', () => {
+ beforeEach(() => clearPlanDataCache());
+
+ it('shows nothing while every limit is at 70% or less', async () => {
+ expect(await mount(plan())).toBe(null);
+ });
+
+ it('shows the same limit as the banner: API first at the same urgency, in yellow', async () => {
+ const badge = await mount(plan({ apiCallUsed: '18 K', fileStorageUsed: '799 MB' }));
+ expect(badge.children).toEqual(['72', '%']);
+ expect(badge.props.className).toBe('badge warning');
+ });
+
+ it('turns red above 90% and caps at 100%', async () => {
+ expect((await mount(plan({ dataStorageUsed: '240 MB' }))).props.className).toBe('badge danger');
+ clearPlanDataCache();
+ expect((await mount(plan({ apiCallUsed: '30 K' }))).children).toEqual(['100', '%']);
+ });
+});
+
+describe('PlanUsageBadge on paid plans', () => {
+ beforeEach(() => clearPlanDataCache());
+
+ it('MVP stays yellow, even past 100%, and shows the real percentage', async () => {
+ 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');
+ });
+
+ 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);
+ 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');
+ });
+
+ 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');
+ clearPlanDataCache();
+ expect(await mount(plan({ planName: 'Advanced Plan', apiCallUsed: '4.9 M', apiCallLimit: '5 M' }))).toBe(null);
+ clearPlanDataCache();
+ expect(await mount(plan({ planName: 'Gold Plan', apiCallUsed: '21 M', apiCallLimit: '20 M' }))).toBe(null);
+ });
+
+ it('Dedicated shows nothing', async () => {
+ expect(await mount(plan({ planName: 'Dedicated Plan', dataStorageUsed: '7.9 GB', dataStorageLimit: '8 GB' }))).toBe(null);
+ });
+});
+
+describe('getCachedPlanData', () => {
+ beforeEach(() => clearPlanDataCache());
+
+ it('fetches the plan once per app across sidebar remounts', async () => {
+ const app = { applicationId: 'a', getAppPlanData: jest.fn(() => Promise.resolve({})) };
+ await getCachedPlanData(app);
+ await getCachedPlanData(app);
+ expect(app.getAppPlanData).toHaveBeenCalledTimes(1);
+ });
+
+ it('retries after a failed request', async () => {
+ const app = { applicationId: 'b', getAppPlanData: jest.fn().mockRejectedValueOnce(new Error('down')).mockResolvedValue({}) };
+ await expect(getCachedPlanData(app)).rejects.toThrow('down');
+ await getCachedPlanData(app);
+ expect(app.getAppPlanData).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/src/lib/tests/RegionChange.test.js b/src/lib/tests/RegionChange.test.js
new file mode 100644
index 0000000000..bd354b94a1
--- /dev/null
+++ b/src/lib/tests/RegionChange.test.js
@@ -0,0 +1,76 @@
+// The test preprocessor does not hoist jest.mock, so mocked modules are required after it.
+// It also rewrites `lib/` / `components/` imports two levels up, which from
+// dashboard/Data/AppOverview lands under src/dashboard; map those paths to the real modules.
+jest.mock('../amplitudeEvents', () => ({ amplitudeLogEvent: jest.fn() }));
+jest.mock('../../dashboard/lib/amplitudeEvents', () => require('../amplitudeEvents'), { virtual: true });
+jest.mock('../../dashboard/lib/upgradeEvents', () => jest.requireActual('../upgradeEvents'), { virtual: true });
+jest.mock('../../dashboard/components/B4aModal/B4aModal.react', () => {
+ const React = require('react');
+ return { __esModule: true, default: ({ children }) =>
{children}
};
+}, { virtual: true });
+jest.mock(
+ '../../dashboard/components/UpgradeCheckout/UpgradeCheckout.react',
+ () => ({ UpgradeCheckoutModal: ({ gate }) => require('react').createElement('div', { id: 'checkout' }, gate) }),
+ { virtual: true }
+);
+jest.mock('context/currentApp', () => ({ CurrentApp: require('react').createContext(null) }), { virtual: true });
+jest.mock('dashboard/Data/AppOverview/RegionChange.scss', () => ({}), { virtual: true });
+
+const React = require('react');
+const renderer = require('react-test-renderer');
+const { CurrentApp } = require('context/currentApp');
+const { amplitudeLogEvent } = require('../amplitudeEvents');
+const RegionChange = require('../../dashboard/Data/AppOverview/RegionChange.react').default;
+
+const FORM = 'https://back4app.typeform.com/to/kMjTovFj?appId=app-1';
+
+const mount = isFree => {
+ let tree;
+ renderer.act(() => {
+ tree = renderer.create(
+
+
+
+ );
+ });
+ return tree;
+};
+
+const click = (tree, predicate) =>
+ renderer.act(() => tree.root.find(predicate).props.onClick({ preventDefault: () => {} }));
+
+// Region buttons in picker order: Europe, South Korea, India, Australia, Singapore.
+const chooseRegion = (tree, index) =>
+ renderer.act(() => tree.root.findAll(node => node.type === 'button')[index].props.onClick());
+
+describe('RegionChange', () => {
+ beforeEach(() => amplitudeLogEvent.mockClear());
+
+ it('sends paid apps straight to the migration form', () => {
+ const link = mount(false).root.findByType('a');
+ expect(link.props.href).toBe(FORM);
+ });
+
+ it('on Free, a paid region opens the MVP checkout', () => {
+ const tree = mount(true);
+ click(tree, node => node.type === 'a');
+ expect(tree.root.findAll(node => node.props.id === 'picker').length).toBe(1);
+ expect(amplitudeLogEvent).toHaveBeenCalledWith('baas_upgrade_gate_viewed', { gate: 'region_change', app_id: 'app-1' });
+
+ chooseRegion(tree, 4);
+ expect(amplitudeLogEvent).toHaveBeenCalledWith('baas_upgrade_gate_clicked', { gate: 'region_change', app_id: 'app-1' });
+ expect(tree.root.findByProps({ id: 'checkout' }).props.children).toBe('region_change');
+ });
+
+ it('on Free, Europe goes to the migration form without a checkout', () => {
+ const open = jest.fn();
+ global.window = { open };
+ const tree = mount(true);
+ click(tree, node => node.type === 'a');
+ chooseRegion(tree, 0);
+ delete global.window;
+
+ expect(open).toHaveBeenCalledWith(FORM, '_blank', 'noopener,noreferrer');
+ expect(tree.root.findAll(node => node.props.id === 'checkout').length).toBe(0);
+ });
+});
diff --git a/src/lib/tests/UpgradeCheckout.test.js b/src/lib/tests/UpgradeCheckout.test.js
index d561771900..caa36ffc73 100644
--- a/src/lib/tests/UpgradeCheckout.test.js
+++ b/src/lib/tests/UpgradeCheckout.test.js
@@ -11,14 +11,15 @@ jest.mock('../../components/Popover/Popover.react', () => {
return { __esModule: true, default: ({ children }) =>
{children}
};
});
jest.mock('context/currentApp', () => ({ CurrentApp: require('react').createContext(null) }), { virtual: true });
+jest.mock('dashboard/Data/AppOverview/usageAlert', () => jest.requireActual('../../dashboard/Data/AppOverview/usageAlert'), { virtual: true });
jest.mock(
- 'dashboard/AppPlan/AppPlan.react',
+ 'dashboard/AppPlan/prices',
() => ({
prices: [
{
name: 'MVP', pricePerMonth: '25', pricePerYear: '15', savePercent: '40%',
monthlyPlanId: 'mvp-m', annuallyPlanId: 'mvp-y', monthlyProductId: 'pri_mvp_m', annuallyProductId: 'pri_mvp_y',
- details: [{ text: 'Web hosting & custom domain' }, { number: '500 K', text: 'Requests' }],
+ details: [{ text: 'Web hosting on a b4a.app subdomain' }, { number: '500 K', text: 'Requests' }],
},
{
name: 'Pay As You Go', pricePerMonth: '100', pricePerYear: '80', savePercent: '20%',
@@ -41,8 +42,17 @@ const { CurrentApp } = require('context/currentApp');
const { amplitudeLogEvent } = require('../amplitudeEvents');
const { initPaddle, recordSubscription } = require('../paddleCheckout');
const { UpgradeCheckoutModal, UpgradeGateButton } = require('../../components/UpgradeCheckout/UpgradeCheckout.react');
+const BackupUpsell = require('../../components/UpgradeCheckout/BackupUpsell.react').default;
-const app = { applicationId: 'app-1', slug: 'my-app', custom: { isOwner: true } };
+const { clearPlanDataCache } = require('../planDataCache');
+
+const appOnPlan = planName => ({
+ applicationId: 'app-1',
+ slug: 'my-app',
+ custom: { isOwner: true },
+ getAppPlanData: () => Promise.resolve({ planName }),
+});
+let app = appOnPlan('Free Plan');
const fakePaddle = () => ({ Checkout: { open: jest.fn(), updateCheckout: jest.fn(), close: jest.fn() } });
@@ -83,7 +93,7 @@ describe('UpgradeCheckoutModal', () => {
});
it('opens the MVP yearly checkout right away for a custom domain paywall', async () => {
- const tree = await mount(
{}} />);
+ const tree = await mount( {}} />);
expect(paddle.Checkout.open).toHaveBeenCalledTimes(1);
const options = paddle.Checkout.open.mock.calls[0][0];
@@ -93,16 +103,16 @@ describe('UpgradeCheckoutModal', () => {
expect(options.settings.frameTarget).toBe('upgrade-checkout-frame');
expect(loggedEvents('baas_checkout_opened')).toEqual([
- { app_id: 'app-1', plan: 'MVP', cycle: 'yearly', source: 'gate', gate: 'custom_domain' },
+ { app_id: 'app-1', plan: 'MVP', cycle: 'yearly', source: 'gate', gate: 'web_hosting' },
]);
const text = textOf(tree);
- expect(text).toContain('Put your API and pages on your own domain');
+ expect(text).toContain('Host your pages on a b4a.app subdomain');
expect(text).toContain('Upgrade to ');
- expect(text).toContain('Web hosting & custom domain');
+ expect(text).toContain('Web hosting on a b4a.app subdomain');
});
it('starts on yearly and switches the open checkout to monthly without reopening it', async () => {
- const tree = await mount( {}} />);
+ const tree = await mount( {}} />);
// $15/month billed yearly is $180 today.
expect(textOf(tree)).toContain('180');
const radios = tree.root.findAll(node => node.type === 'input' && node.props.type === 'radio');
@@ -116,7 +126,7 @@ describe('UpgradeCheckoutModal', () => {
customData: { appId: 'app-1', planId: 'mvp-m' },
});
expect(loggedEvents('baas_checkout_cycle_changed')).toEqual([
- { app_id: 'app-1', plan: 'MVP', cycle: 'monthly', source: 'gate', gate: 'custom_domain' },
+ { app_id: 'app-1', plan: 'MVP', cycle: 'monthly', source: 'gate', gate: 'web_hosting' },
]);
expect(textOf(tree)).toContain('Billed monthly');
});
@@ -156,12 +166,12 @@ describe('UpgradeCheckoutModal', () => {
});
it('follows the light or dark preference, including the Paddle frame', async () => {
- await mount( {}} />);
+ await mount( {}} />);
expect(paddle.Checkout.open.mock.calls[0][0].settings.theme).toBe('light');
paddle.Checkout.open.mockClear();
global.window = { matchMedia: query => ({ matches: query === '(prefers-color-scheme: dark)' }) };
- const tree = await mount( {}} />);
+ const tree = await mount( {}} />);
delete global.window;
expect(paddle.Checkout.open.mock.calls[0][0].settings.theme).toBe('dark');
@@ -171,7 +181,7 @@ describe('UpgradeCheckoutModal', () => {
it('sells web hosting on MVP from the Overview', async () => {
const tree = await mount( {}} />);
expect(paddle.Checkout.open.mock.calls[0][0].items[0].priceId).toBe('pri_mvp_y');
- expect(textOf(tree)).toContain('Host your pages and use your own domain');
+ expect(textOf(tree)).toContain('Host your pages on a b4a.app subdomain');
});
it('sells HTTPS on Pay As You Go and tells the buyer support turns it on', async () => {
@@ -196,6 +206,35 @@ describe('UpgradeCheckoutModal', () => {
}
});
+ it('sells MVP from the job limit on Free', async () => {
+ const tree = await mount( {}} />);
+ expect(paddle.Checkout.open.mock.calls[0][0].items[0].priceId).toBe('pri_mvp_y');
+ expect(textOf(tree)).toContain('Schedule more background jobs');
+ });
+
+ it('sells Dedicated on yearly from the database profiler', async () => {
+ const tree = await mount( {}} />);
+ expect(paddle.Checkout.open.mock.calls[0][0].items[0].priceId).toBe('pri_ded_y');
+ expect(textOf(tree)).toContain('See which queries slow your app down');
+ });
+
+ it('after paying for a region, points to the migration form for this app', async () => {
+ const tree = await mount( {}} />);
+ expect(textOf(tree)).toContain('Run your app closer to your users');
+ await renderer.act(async () => {
+ await paddleCallback({ name: 'checkout.completed', data: {} });
+ });
+ expect(tree.root.findByProps({ href: 'https://back4app.typeform.com/to/kMjTovFj?appId=app-1' })).toBeTruthy();
+ expect(textOf(tree)).toContain('Request the migration');
+ });
+
+ it('sells custom domains on Pay As You Go, where the backend unlocks them', async () => {
+ const tree = await mount( {}} />);
+ expect(paddle.Checkout.open.mock.calls[0][0].items[0].priceId).toBe('pri_payg_y');
+ expect(textOf(tree)).toContain('Use your own domain');
+ expect(textOf(tree)).toContain('Pay As You Go includes custom domains with HTTPS.');
+ });
+
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');
@@ -208,6 +247,8 @@ describe('UpgradeCheckoutModal', () => {
describe('UpgradeGateButton', () => {
beforeEach(() => {
+ clearPlanDataCache();
+ app = appOnPlan('Free Plan');
amplitudeLogEvent.mockClear();
initPaddle.mockReset();
initPaddle.mockImplementation(() => Promise.resolve(fakePaddle()));
@@ -222,7 +263,7 @@ describe('UpgradeGateButton', () => {
const preventDefault = jest.fn();
await renderer.act(async () => {
- tree.root.findByProps({ id: 'trigger' }).props.onClick({ preventDefault });
+ await tree.root.findByProps({ id: 'trigger' }).props.onClick({ preventDefault });
});
expect(preventDefault).toHaveBeenCalled();
@@ -231,4 +272,63 @@ describe('UpgradeGateButton', () => {
expect(textOf(tree)).toContain('Get the latest MongoDB');
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');
+ const assign = jest.fn();
+ global.window = { location: { assign } };
+ const tree = await mount(
+ Upgrade Plan } />
+ );
+ await renderer.act(async () => {
+ await tree.root.findByProps({ id: 'trigger' }).props.onClick({ preventDefault: () => {} });
+ });
+ delete global.window;
+
+ 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' }]);
+ });
+});
+
+describe('BackupUpsell', () => {
+ beforeEach(() => clearPlanDataCache());
+
+ const mountWithPlan = async planName => {
+ let tree;
+ const appWithPlan = { ...app, getAppPlanData: () => Promise.resolve({ planName }) };
+ await renderer.act(async () => {
+ tree = renderer.create(
+
+
+ ,
+ { createNodeMock: () => ({}) }
+ );
+ });
+ return tree;
+ };
+
+ beforeEach(() => {
+ amplitudeLogEvent.mockClear();
+ initPaddle.mockReset();
+ initPaddle.mockImplementation(() => Promise.resolve(fakePaddle()));
+ });
+
+ it('warns Free apps that deleted data cannot be recovered and offers backups', async () => {
+ const tree = await mountWithPlan('Free Plan');
+ expect(textOf(tree)).toContain('No backups on Free');
+ expect(textOf(tree)).toContain('Deleted data can\'t be recovered.');
+ expect(loggedEvents('baas_upgrade_gate_viewed')).toEqual([{ gate: 'backup_delete_class', app_id: 'app-1' }]);
+
+ await renderer.act(async () => {
+ await tree.root.findByType('button').props.onClick({ preventDefault: () => {} });
+ });
+ expect(textOf(tree)).toContain('Keep daily backups of your data');
+ });
+
+ it('stays out of the way on paid plans', async () => {
+ const tree = await mountWithPlan('MVP Plan');
+ expect(tree.toJSON()).toBe(null);
+ expect(loggedEvents('baas_upgrade_gate_viewed')).toEqual([]);
+ });
});
diff --git a/src/lib/tests/usageAlert.test.js b/src/lib/tests/usageAlert.test.js
new file mode 100644
index 0000000000..4c09cbfd6c
--- /dev/null
+++ b/src/lib/tests/usageAlert.test.js
@@ -0,0 +1,185 @@
+const { getUsageAlert } = require('../../dashboard/Data/AppOverview/usageAlert');
+
+const plan = overrides => ({
+ planName: 'Free Plan',
+ apiCallUsed: '2',
+ apiCallLimit: '25 K',
+ fileStorageUsed: '0.00 KB',
+ fileStorageLimit: '1 GB',
+ dataStorageUsed: '400 KB',
+ dataStorageLimit: '0.25 GB',
+ ...overrides,
+});
+
+describe('getUsageAlert', () => {
+ it('stays quiet while every limit is at 70% or less', () => {
+ expect(getUsageAlert(plan({ apiCallUsed: '17.5 K' }))).toBe(null);
+ });
+
+ it('warns in yellow above 70% and names the resource and the consequence', () => {
+ const alert = getUsageAlert(plan({ apiCallUsed: '21 K' }));
+ expect(alert.level).toBe('warning');
+ expect(alert.message).toBe(
+ 'You\'ve used 84% of this month\'s API requests (21 K of 25 K). At 100%, your app stops responding and your users start getting errors.'
+ );
+ });
+
+ it('at the same urgency, talks about API requests first, then database, then files', () => {
+ const alert = getUsageAlert(plan({ apiCallUsed: '18 K', fileStorageUsed: '799 MB', dataStorageUsed: '190 MB' }));
+ expect(alert.level).toBe('warning');
+ expect(alert.message).toContain('You\'ve used 72% of this month\'s API requests (18 K of 25 K).');
+ expect(alert.message).toContain('2 other limits are close too.');
+
+ const noApi = getUsageAlert(plan({ fileStorageUsed: '799 MB', dataStorageUsed: '190 MB' }));
+ expect(noApi.message).toContain('Your database storage is 74% full');
+ });
+
+ it('a more urgent limit beats the priority order', () => {
+ const alert = getUsageAlert(plan({ apiCallUsed: '18 K', fileStorageUsed: '990 MB' }));
+ expect(alert.level).toBe('danger');
+ expect(alert.message).toContain('Your file storage is 96% full');
+ });
+
+ it('turns red above 90% and leads with the fullest resource, counting the others', () => {
+ const alert = getUsageAlert(plan({ apiCallUsed: '21 K', fileStorageUsed: '820 MB', dataStorageUsed: '235 MB' }));
+ expect(alert.level).toBe('danger');
+ expect(alert.message).toContain('Your database storage is 91% full (235 MB of 0.25 GB).');
+ expect(alert.message).toContain('2 other limits are close too.');
+ });
+
+ it('never suggests waiting for the next month', () => {
+ const alert = getUsageAlert(plan({ apiCallUsed: '24 K' }));
+ expect(alert.message).not.toMatch(/month\b.*(reset|renew|until)/i);
+ expect(alert.message).not.toMatch(/until/i);
+ });
+
+ it('at 100% on Free, warns that the app can stop at any moment (it still serves requests)', () => {
+ const alert = getUsageAlert(plan({ apiCallUsed: '25 K' }));
+ expect(alert.level).toBe('danger');
+ expect(alert.message).toBe(
+ '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.message).not.toMatch(/stopped/);
+ });
+
+ it('reports a blocked app once the Free plan is paused', () => {
+ const alert = getUsageAlert(plan({ planName: 'Free Plan - Paused', apiCallUsed: '25 K' }));
+ expect(alert.level).toBe('blocked');
+ expect(alert.message).toBe(
+ 'Your app stopped responding: it reached its API requests limit. Your app\'s users are getting errors right now.'
+ );
+ expect(alert.actionLabel).toBe('Upgrade to bring it back');
+ });
+
+ it('reports a blocked app when the server already answers 402, even with stale usage', () => {
+ expect(getUsageAlert(plan(), true).level).toBe('blocked');
+ expect(getUsageAlert(new Error('plan data failed'), true).message).toContain('reached its plan limit');
+ });
+
+ 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', () => {
+ 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.actionLabel).toBe('Upgrade to Pay As You Go');
+ });
+
+ it('past 100% it keeps running and pays the overage, still yellow', () => {
+ const alert = getUsageAlert(mvp({ apiCallUsed: '520 K' }));
+ expect(alert.level).toBe('warning');
+ 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.'
+ );
+ expect(alert.message).not.toMatch(/stop/i);
+ });
+
+ it('uses the storage overage prices', () => {
+ expect(getUsageAlert(mvp({ dataStorageUsed: '1.1 GB' })).message).toContain('extra database storage is billed at $15 per GB');
+ 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');
+ });
+ });
+
+ 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('over its included usage, explains the overage and offers Dedicated', () => {
+ const alert = getUsageAlert(payg({ apiCallUsed: '5.5 M' }));
+ expect(alert.level).toBe('info');
+ 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.'
+ );
+ expect(alert.actionLabel).toBe('Upgrade to Dedicated');
+ });
+ });
+
+ 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', () => {
+ ['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.actionLabel).toBe('Upgrade to MVP');
+ expect(alert.message).not.toMatch(/billed/);
+ });
+ expect(getUsageAlert(legacy('Starter Plan', { apiCallUsed: '49 K' })).message).toBe(
+ 'You\'ve used 98% of this month\'s API requests on Starter (49 K of 50 K).'
+ );
+ expect(getUsageAlert(legacy('Starter Plan', { apiCallUsed: '52 K' })).message).toBe(
+ 'This app used 104% of its Starter API requests (52 K of 50 K).'
+ );
+ });
+
+ it('Basic, Intermediate and Standard offer Pay As You Go', () => {
+ ['Basic Plan', 'Intermediate Plan', 'Standard Plan'].forEach(planName => {
+ const alert = getUsageAlert(legacy(planName, { apiCallUsed: '40 K' }));
+ expect(alert.level).toBe('warning');
+ 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', () => {
+ 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);
+ const alert = getUsageAlert(advanced({ apiCallUsed: '5.5 M' }));
+ expect(alert.level).toBe('info');
+ 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.'
+ );
+ 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');
+ });
+
+ 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');
+ });
+ });
+
+ it('Dedicated, Silver, Gold, Platinum and custom plans get no usage warnings', () => {
+ ['Dedicated Plan', 'Silver Plan', 'Gold Plan', 'Platinum Plan', 'Custom Plan'].forEach(planName => {
+ expect(getUsageAlert(plan({ planName, dataStorageUsed: '8.5 GB', dataStorageLimit: '8 GB' }))).toBe(null);
+ });
+ });
+
+ it('shows nothing when plan data is missing and the app is not blocked', () => {
+ expect(getUsageAlert(null)).toBe(null);
+ expect(getUsageAlert(new Error('plan data failed'))).toBe(null);
+ });
+});
diff --git a/src/lib/upgradeEvents.js b/src/lib/upgradeEvents.js
index a46f3d5057..69977227ee 100644
--- a/src/lib/upgradeEvents.js
+++ b/src/lib/upgradeEvents.js
@@ -27,11 +27,22 @@ export const UpgradeGate = {
OVERVIEW_PLAN_BADGE: 'overview_plan_badge',
OVERVIEW_WEB_HOSTING: 'overview_web_hosting',
HTTPS: 'https',
+ BACKUP_DELETE_CLASS: 'backup_delete_class',
+ BACKUP_DELETE_ROWS: 'backup_delete_rows',
+ BACKUP_DELETE_COLUMN: 'backup_delete_column',
+ USAGE_LIMIT: 'usage_limit',
+ LOGS_RETENTION: 'logs_retention',
+ REGION_CHANGE: 'region_change',
COMPLIANCE_HIPAA: 'compliance_hipaa',
COMPLIANCE_SOC2: 'compliance_soc2',
COMPLIANCE_ISO27001: 'compliance_iso27001',
};
+export const SUPPORT_TICKET_URL = 'https://help.back4app.com/hc/en-us/requests/new';
+
+// Region migrations are requested through this form, on any plan.
+export const regionChangeFormUrl = appId => `https://back4app.typeform.com/to/kMjTovFj?appId=${appId}`;
+
export const planUsagePath = (slug, gate) => `/apps/${slug}/plan-usage?gate=${gate}`;
export const getGateFromSearch = (search) => {