Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
802594c
Delete confirmations: offer daily backups on Free before data is lost
alyssoncm Oct 5, 2026
5479c5f
Overview: usage banner near and over plan limits
alyssoncm Oct 5, 2026
8a19acb
Jobs limit: Free opens the MVP checkout directly
alyssoncm Oct 5, 2026
198dbdd
Database Profiler: use the standard direct checkout, yearly first
alyssoncm Oct 5, 2026
4420401
Logs: say how to get older logs, offer a week of access logs on Free
alyssoncm Oct 5, 2026
1dc6abb
Backup notice: dialog row with the green upgrade button, also on colu…
alyssoncm Oct 6, 2026
ab4a72c
Usage banner: most urgent limit first, then API, database, files
alyssoncm Oct 6, 2026
d371571
Sidebar: usage percentage next to Plan Usage when a limit is close
alyssoncm Oct 6, 2026
5d5b95b
Plan Usage: usage banner on top, MVP recommended on Free
alyssoncm Oct 6, 2026
f92cf2e
Sidebar usage badge: right-aligned on unselected rows, no hover under…
alyssoncm Oct 6, 2026
ec552b8
Hosting region: on Free, paid regions open the MVP checkout
alyssoncm Oct 6, 2026
5c81c26
Usage warnings by plan: MVP yellow with overage prices, Pay As You Go…
alyssoncm Oct 6, 2026
2c23bea
Custom domains start on Pay As You Go: stop selling them on MVP
alyssoncm Oct 6, 2026
b194f44
Direct checkout only for Free; paid plans go to Plan Usage
alyssoncm Oct 6, 2026
7f03e5a
Free over its limit: "can stop at any moment" until the plan is paused
alyssoncm Oct 6, 2026
3876e67
Usage warnings on legacy plans: each one points at the next plan up
charles-ramos Oct 6, 2026
4a63784
Advanced over its limit: same overage message as Pay As You Go
charles-ramos Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/components/Sidebar/B4aSidebar.react.js
Original file line number Diff line number Diff line change
Expand Up @@ -310,10 +310,11 @@ const B4aSidebar = ({
link,
subsections,
badgeParams,
badge: sectionBadge,
headerAction
}) => {
const active = name === section;
const badge = badgeParams && <B4aBadge {...badgeParams} /> || ''
const badge = sectionBadge || (badgeParams && <B4aBadge {...badgeParams} />) || ''
// If link points to another component, adds the prefix
link = link.startsWith('/') ? prefix + link : link;
return (
Expand Down
32 changes: 32 additions & 0 deletions src/components/Sidebar/PlanUsageBadge.react.js
Original file line number Diff line number Diff line change
@@ -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 <span className={`${styles.badge} ${styles[badge.level]}`}>{badge.percent}%</span>;
};

export default PlanUsageBadge;
35 changes: 35 additions & 0 deletions src/components/Sidebar/PlanUsageBadge.scss
Original file line number Diff line number Diff line change
@@ -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;
}
48 changes: 48 additions & 0 deletions src/components/UpgradeCheckout/BackupUpsell.react.js
Original file line number Diff line number Diff line change
@@ -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 (
<Field
labelPadding={24}
label={
<Label
text="No backups on Free"
description="Deleted data can't be recovered. MVP keeps a daily backup for 7 days."
/>
}
input={
<div style={{ width: '100%', padding: '0 1rem', textAlign: 'right' }}>
<UpgradeGateButton gate={gate} value="Get daily backups" />
</div>
}
/>
);
};

export default BackupUpsell;
75 changes: 65 additions & 10 deletions src/components/UpgradeCheckout/UpgradeCheckout.react.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@ import Icon from 'components/Icon/Icon.react';
import AccountManager from 'lib/AccountManager';
import { CurrentApp } from 'context/currentApp';
import { amplitudeLogEvent } from 'lib/amplitudeEvents';
import { UpgradeEvent, UpgradeGate, UpgradeGateView, logGateClicked, planUsagePath } from 'lib/upgradeEvents';
import { SUPPORT_TICKET_URL, UpgradeEvent, UpgradeGate, UpgradeGateView, logGateClicked, planUsagePath, regionChangeFormUrl } from 'lib/upgradeEvents';
import { Cycle, initPaddle, paddlePlanId, paddlePriceId, recordSubscription } from 'lib/paddleCheckout';
import { prices } from 'dashboard/AppPlan/AppPlan.react';
import { prices } from 'dashboard/AppPlan/prices';
import { getCachedPlanData } from 'lib/planDataCache';
import { planKindOf } from 'dashboard/Data/AppOverview/usageAlert';
import styles from './UpgradeCheckout.scss';

const CHECKOUT_FRAME_CLASS = 'upgrade-checkout-frame';
const ORIGIN = new Position(0, 0);
const SUPPORT_TICKET_URL = 'https://help.back4app.com/hc/en-us/requests/new';

// Follows the OS/browser light or dark preference. Read once: Paddle cannot switch theme after opening.
const prefersDark = () =>
Expand All @@ -22,17 +23,30 @@ const prefersDark = () =>
const MVP_AS_A_WHOLE = {
plan: 'MVP',
headline: 'Take this app to production',
subline: 'Web hosting and your own domain, daily backups, the latest MongoDB and your team in the app.',
subline: 'Web hosting, daily backups, the latest MongoDB and your team in the app.',
};

const BACKUPS = {
plan: 'MVP',
headline: 'Keep daily backups of your data',
subline: 'MVP keeps a daily backup of your app for 7 days.',
};

// What the user asked for when they hit the paywall, and the cheapest plan that unlocks it.
const GATE_OFFERS = {
[UpgradeGate.WEB_HOSTING]: { plan: 'MVP', headline: 'Host your pages and use your own domain' },
[UpgradeGate.OVERVIEW_WEB_HOSTING]: { plan: 'MVP', headline: 'Host your pages and use your own domain' },
[UpgradeGate.CUSTOM_DOMAIN]: { plan: 'MVP', headline: 'Put your API and pages on your own domain' },
[UpgradeGate.WEB_HOSTING]: { plan: 'MVP', headline: 'Host your pages on a b4a.app subdomain' },
[UpgradeGate.OVERVIEW_WEB_HOSTING]: { plan: 'MVP', headline: 'Host your pages on a b4a.app subdomain' },
// Custom domains start on Pay As You Go (backend permission canChangeCustomDomain).
[UpgradeGate.CUSTOM_DOMAIN]: {
plan: 'Pay As You Go',
headline: 'Use your own domain',
subline: 'Pay As You Go includes custom domains with HTTPS.',
},
[UpgradeGate.EMAIL_TEMPLATES]: { plan: 'MVP', headline: 'Send emails with your brand and words' },
[UpgradeGate.PARSE_OPTIONS]: { plan: 'MVP', headline: 'Tune your Parse Server' },
[UpgradeGate.COLLABORATORS]: { plan: 'MVP', headline: 'Bring your team into this app' },
// Only Free reaches this checkout from the job limit; paid plans go to Plan Usage.
[UpgradeGate.JOBS]: { plan: 'MVP', headline: 'Schedule more background jobs', subline: 'MVP lets you schedule up to 3 jobs.' },
// The overview's generic upgrade entry points: no single feature asked for, so they sell MVP as a whole.
[UpgradeGate.OVERVIEW_PLAN_CARD]: MVP_AS_A_WHOLE,
[UpgradeGate.OVERVIEW_PLAN_BADGE]: MVP_AS_A_WHOLE,
Expand All @@ -41,12 +55,41 @@ 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.
[UpgradeGate.USAGE_LIMIT]: {
plan: 'MVP',
headline: 'Keep your app running',
subline: 'MVP raises your limits to 500K requests, 1 GB of database and 50 GB of files.',
},
// Shown inside delete confirmations on Free, where nothing can be recovered.
[UpgradeGate.BACKUP_DELETE_CLASS]: BACKUPS,
[UpgradeGate.BACKUP_DELETE_ROWS]: BACKUPS,
[UpgradeGate.BACKUP_DELETE_COLUMN]: BACKUPS,
// Paid regions: paying unlocks them, the migration itself is still requested through the form.
[UpgradeGate.REGION_CHANGE]: {
plan: 'MVP',
headline: 'Run your app closer to your users',
subline: 'South Korea, India, Australia and Singapore are available on paid plans.',
nextStep: 'Next, tell us which region to move your app to.',
nextStepLink: { label: 'Request the migration', href: context => regionChangeFormUrl(context.applicationId) },
},
// HTTPS on a custom domain is turned on by support after the upgrade.
[UpgradeGate.HTTPS]: {
plan: 'Pay As You Go',
headline: 'Serve your custom domain over HTTPS',
subline: 'Your domain is on HTTP, so browsers mark it "Not secure".',
nextStep: 'Next, open a support ticket and our team will enable HTTPS on your domain.',
nextStepLink: { label: 'Open a ticket', href: () => SUPPORT_TICKET_URL },
},
[UpgradeGate.LOGS_RETENTION]: {
plan: 'MVP',
headline: 'Debug with a week of access logs',
subline: 'Free keeps access logs for 1 day; MVP keeps 7 days.',
},
[UpgradeGate.DB_PROFILER]: {
plan: 'Dedicated',
headline: 'See which queries slow your app down',
subline: 'The Query Performance Monitor comes with Dedicated.',
},
[UpgradeGate.COMPLIANCE_SOC2]: { plan: 'Pay As You Go', headline: 'Pass your customer\'s security review', subline: 'SOC 2 Type 2 certified infrastructure.' },
[UpgradeGate.COMPLIANCE_ISO27001]: { plan: 'Pay As You Go', headline: 'Pass your customer\'s security review', subline: 'ISO 27001 certified infrastructure.' },
Expand Down Expand Up @@ -203,7 +246,7 @@ export const UpgradeCheckoutModal = ({ gate, onClose }) => {
{offer.nextStep ? (
<div className={styles.nextStep}>
{offer.nextStep}{' '}
<a href={SUPPORT_TICKET_URL} target="_blank" rel="noopener noreferrer">Open a ticket</a>
<a href={offer.nextStepLink.href(context)} target="_blank" rel="noopener noreferrer">{offer.nextStepLink.label}</a>
</div>
) : null}
</div>
Expand All @@ -226,7 +269,16 @@ export const UpgradeCheckoutModal = ({ gate, onClose }) => {
);
};

// Paywall entry point: logs the gate view, and on click opens the direct checkout.
// Plan Usage for this app, keeping the gate so its checkout events know where the user came from.
const goToPlanUsage = (slug, gate) => {
/* global b4aSettings */
const base = (typeof b4aSettings !== 'undefined' && b4aSettings.BACKEND_DASHBOARD_PATH) || '';
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.
// `renderTrigger(open)` lets anchors and custom buttons keep their look.
export const UpgradeGateButton = ({ gate, renderTrigger, value = 'Upgrade Plan' }) => {
const context = useContext(CurrentApp);
Expand All @@ -236,7 +288,10 @@ export const UpgradeGateButton = ({ gate, renderTrigger, value = 'Upgrade Plan'
event.preventDefault();
}
logGateClicked(gate, context.applicationId);
setOpen(true);
return getCachedPlanData(context)
.then(planData => planKindOf(planData) === 'free')
.catch(() => false)
.then(isFree => (isFree ? setOpen(true) : goToPlanUsage(context.slug, gate)));
};

return (
Expand Down
32 changes: 32 additions & 0 deletions src/components/UsageLimitBanner/UsageLimitBanner.react.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import React from 'react';
import { Link } from 'react-router-dom';
import Icon from 'components/Icon/Icon.react';
import { UpgradeGate, planUsagePath } from 'lib/upgradeEvents';
import { UpgradeGateButton } from 'components/UpgradeCheckout/UpgradeCheckout.react';
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}).
const UsageLimitBanner = ({ planData, blocked, slug, showPlansLink = true }) => {
const alert = getUsageAlert(planData, blocked);
if (!alert) {
return null;
}
let action = null;
if (alert.action === 'checkout') {
action = <UpgradeGateButton gate={UpgradeGate.USAGE_LIMIT} value={alert.actionLabel} />;
} else if (showPlansLink) {
action = <Link className={styles.link} to={planUsagePath(slug, UpgradeGate.USAGE_LIMIT)}>{alert.actionLabel}</Link>;
}
return (
<div className={`${styles.banner} ${styles[alert.level]}`}>
<Icon name="warn-triangle-outline" width={18} height={18} fill="currentColor" />
<div className={styles.text}>{alert.message}</div>
{action}
</div>
);
};

export default UsageLimitBanner;
57 changes: 57 additions & 0 deletions src/components/UsageLimitBanner/UsageLimitBanner.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
@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.
.banner {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
padding: 12px 16px;
border: 1px solid;
border-radius: 8px;
color: $white;
font-size: 14px;
line-height: 1.4;
}

.warning {
border-color: rgba(251, 255, 59, 0.5);
background: rgba(251, 255, 59, 0.06);

svg {
color: #FBFF3B;
}
}

.danger,
.blocked {
border-color: rgba(232, 92, 62, 0.6);
background: rgba(232, 92, 62, 0.1);

svg {
color: #E85C3E;
}
}

.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;
}

.link {
flex-shrink: 0;
color: #27AE60;
font-weight: 600;
white-space: nowrap;
}
Loading
Loading