From 802594c384e5b5ded40b8ce36bacd8fdd77249ae Mon Sep 17 00:00:00 2001 From: Alysson Melo Date: Mon, 5 Oct 2026 17:04:06 -0300 Subject: [PATCH 01/17] Delete confirmations: offer daily backups on Free before data is lost Free apps have no backups, so deleting a class or rows is permanent. The "Delete this class?" and "Delete rows" confirmations now say so on Free and link to the MVP checkout ("Keep daily backups of your data"). Delete still works as before; paid plans see nothing new; relation detach is skipped since no data is removed. New gates: backup_delete_class, backup_delete_rows. Co-Authored-By: Claude Opus 5.5 --- .../UpgradeCheckout/BackupUpsell.react.js | 45 +++++++++++++++++++ .../UpgradeCheckout/UpgradeCheckout.react.js | 9 ++++ .../UpgradeCheckout/UpgradeCheckout.scss | 25 +++++++++++ .../Data/Browser/DeleteRowsDialog.react.js | 3 ++ .../Data/Browser/DropClassDialog.react.js | 3 ++ src/lib/tests/UpgradeCheckout.test.js | 40 +++++++++++++++++ src/lib/upgradeEvents.js | 2 + 7 files changed, 127 insertions(+) create mode 100644 src/components/UpgradeCheckout/BackupUpsell.react.js diff --git a/src/components/UpgradeCheckout/BackupUpsell.react.js b/src/components/UpgradeCheckout/BackupUpsell.react.js new file mode 100644 index 0000000000..f847a12684 --- /dev/null +++ b/src/components/UpgradeCheckout/BackupUpsell.react.js @@ -0,0 +1,45 @@ +import React, { useContext, useEffect, useState } from 'react'; +import { CurrentApp } from 'context/currentApp'; +import { UpgradeGateButton } from 'components/UpgradeCheckout/UpgradeCheckout.react'; +import styles from './UpgradeCheckout.scss'; + +// 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. +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 ( +
+ + Free apps don't keep backups, so this can't be recovered. MVP keeps a daily backup for 7 days. + + ( + + Get daily backups + + )} + /> +
+ ); +}; + +export default BackupUpsell; diff --git a/src/components/UpgradeCheckout/UpgradeCheckout.react.js b/src/components/UpgradeCheckout/UpgradeCheckout.react.js index 51b48c4865..29ee3f3bc9 100644 --- a/src/components/UpgradeCheckout/UpgradeCheckout.react.js +++ b/src/components/UpgradeCheckout/UpgradeCheckout.react.js @@ -25,6 +25,12 @@ const MVP_AS_A_WHOLE = { subline: 'Web hosting and your own domain, 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' }, @@ -41,6 +47,9 @@ const GATE_OFFERS = { headline: 'Get the latest MongoDB', subline: 'Faster queries and the newest MongoDB features.', }, + // Shown inside delete confirmations on Free, where nothing can be recovered. + [UpgradeGate.BACKUP_DELETE_CLASS]: BACKUPS, + [UpgradeGate.BACKUP_DELETE_ROWS]: BACKUPS, // HTTPS on a custom domain is turned on by support after the upgrade. [UpgradeGate.HTTPS]: { plan: 'Pay As You Go', diff --git a/src/components/UpgradeCheckout/UpgradeCheckout.scss b/src/components/UpgradeCheckout/UpgradeCheckout.scss index 84598b7fb2..afafc40d39 100644 --- a/src/components/UpgradeCheckout/UpgradeCheckout.scss +++ b/src/components/UpgradeCheckout/UpgradeCheckout.scss @@ -208,6 +208,31 @@ } } +// Sits inside the (light) delete confirmation modal, not the themed checkout. +.backupUpsell { + @include InterFont; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin: 0 1rem 0.5rem; + padding: 0.75rem 1rem; + border: 1px solid #e2e6ec; + border-radius: 8px; + background: #f5f7fa; + color: #10203a; + font-size: 0.875rem; + line-height: 1.4; + text-align: left; +} + +.backupUpsellLink { + flex-shrink: 0; + color: $cta-green; + font-weight: 600; + white-space: nowrap; +} + .error { padding: 1rem 0; color: var(--uc-text); diff --git a/src/dashboard/Data/Browser/DeleteRowsDialog.react.js b/src/dashboard/Data/Browser/DeleteRowsDialog.react.js index ca269f86f0..e177187bd6 100644 --- a/src/dashboard/Data/Browser/DeleteRowsDialog.react.js +++ b/src/dashboard/Data/Browser/DeleteRowsDialog.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 DeleteRowsDialog extends React.Component { constructor() { @@ -102,6 +104,7 @@ export default class DeleteRowsDialog extends React.Component { onConfirm={this.props.onConfirm} buttonsInCenter={false} > + {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} > + { expect(textOf(tree)).toContain('Faster queries and the newest MongoDB features.'); }); }); + +describe('BackupUpsell', () => { + 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('this can\'t be recovered'); + expect(loggedEvents('baas_upgrade_gate_viewed')).toEqual([{ gate: 'backup_delete_class', app_id: 'app-1' }]); + + await renderer.act(async () => { + tree.root.findByType('a').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/upgradeEvents.js b/src/lib/upgradeEvents.js index a46f3d5057..498ac6bfe1 100644 --- a/src/lib/upgradeEvents.js +++ b/src/lib/upgradeEvents.js @@ -27,6 +27,8 @@ 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', COMPLIANCE_HIPAA: 'compliance_hipaa', COMPLIANCE_SOC2: 'compliance_soc2', COMPLIANCE_ISO27001: 'compliance_iso27001', From 5479c5fe35363ea4e5301207036694136836cf9e Mon Sep 17 00:00:00 2001 From: Alysson Melo Date: Mon, 5 Oct 2026 17:26:04 -0300 Subject: [PATCH 02/17] Overview: usage banner near and over plan limits Apps over a limit without payment are blocked (402), but the Overview only showed a yellow or red bar. A banner above the app summary now says what is close to the limit and what happens at 100%, without suggesting the user wait for the next month: - above 70% (yellow) and above 90% (red): "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.", plus how many other limits are close - blocked (100% or a 402 from the server): "Your app stopped responding: it reached its ... limit. Your app's users are getting errors right now." Free opens the MVP checkout (new gate usage_limit, "Keep your app running"); paid plans link to Plan Usage. Thresholds match the existing usage bars. getUsagePercent is extracted from getUsageClassName. Co-Authored-By: Claude Opus 5.5 --- .../UpgradeCheckout/UpgradeCheckout.react.js | 6 ++ src/dashboard/AppPlan/usageClassUtils.js | 14 ++++- .../Data/AppOverview/AppOverview.react.js | 30 ++++++++- .../Data/AppOverview/AppOverview.scss | 45 +++++++++++++ src/dashboard/Data/AppOverview/usageAlert.js | 63 +++++++++++++++++++ src/lib/tests/usageAlert.test.js | 57 +++++++++++++++++ src/lib/upgradeEvents.js | 1 + 7 files changed, 212 insertions(+), 4 deletions(-) create mode 100644 src/dashboard/Data/AppOverview/usageAlert.js create mode 100644 src/lib/tests/usageAlert.test.js diff --git a/src/components/UpgradeCheckout/UpgradeCheckout.react.js b/src/components/UpgradeCheckout/UpgradeCheckout.react.js index 29ee3f3bc9..57b2c01b92 100644 --- a/src/components/UpgradeCheckout/UpgradeCheckout.react.js +++ b/src/components/UpgradeCheckout/UpgradeCheckout.react.js @@ -47,6 +47,12 @@ 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, 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/Data/AppOverview/AppOverview.react.js b/src/dashboard/Data/AppOverview/AppOverview.react.js index 4f8b0d6c02..ce8a8afdbb 100644 --- a/src/dashboard/Data/AppOverview/AppOverview.react.js +++ b/src/dashboard/Data/AppOverview/AppOverview.react.js @@ -20,7 +20,8 @@ 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, planUsagePath } from 'lib/upgradeEvents'; +import { getUsageAlert } from './usageAlert'; import { UpgradeGateButton } from 'components/UpgradeCheckout/UpgradeCheckout.react'; import AppOverviewActions from './AppOverviewActions.react'; import ComplianceCard from './ComplianceCard.react'; @@ -51,6 +52,27 @@ const CopyValue = ({ value }) => { // One column of the facts row: small label, the value, and an optional action // or note under it. // A missing value shows as a dash rather than an empty column. +// Near or over a plan limit. Free opens the MVP checkout; other plans open Plan Usage. +const UsageLimitBanner = ({ planData, blocked, slug }) => { + const alert = getUsageAlert(planData, blocked); + if (!alert) { + return null; + } + const isFree = planData && !(planData instanceof Error) && /^free/i.test(planData.planName || ''); + const cta = alert.level === 'blocked' ? 'Upgrade to bring it back' : 'Upgrade to MVP'; + return ( +
+ +
{alert.message}
+ {isFree ? ( + + ) : ( + See plans + )} +
+ ); +}; + // The app's plan next to its name. Free opens the MVP checkout; other plans open Plan Usage. const PlanBadge = ({ planData, slug }) => { if (!planData || planData instanceof Error || !planData.planName) { @@ -283,6 +305,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. */}
diff --git a/src/dashboard/Data/AppOverview/AppOverview.scss b/src/dashboard/Data/AppOverview/AppOverview.scss index 35ff7feeef..8758ddf9d6 100644 --- a/src/dashboard/Data/AppOverview/AppOverview.scss +++ b/src/dashboard/Data/AppOverview/AppOverview.scss @@ -157,6 +157,51 @@ white-space: nowrap; } + // Usage banner above the app summary: yellow above 70%, red above 90% or when blocked. + & .usageBanner { + 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; + } + + & .usageBanner_warning { + border-color: rgba(251, 255, 59, 0.5); + background: rgba(251, 255, 59, 0.06); + + svg { + color: #FBFF3B; + } + } + + & .usageBanner_danger, + & .usageBanner_blocked { + border-color: rgba(232, 92, 62, 0.6); + background: rgba(232, 92, 62, 0.1); + + svg { + color: #E85C3E; + } + } + + & .usageBannerText { + flex: 1; + min-width: 0; + } + + & .usageBannerLink { + flex-shrink: 0; + color: #27AE60; + font-weight: 600; + white-space: nowrap; + } + & .appSummaryNameRow { display: flex; align-items: center; diff --git a/src/dashboard/Data/AppOverview/usageAlert.js b/src/dashboard/Data/AppOverview/usageAlert.js new file mode 100644 index 0000000000..e5b41b69a2 --- /dev/null +++ b/src/dashboard/Data/AppOverview/usageAlert.js @@ -0,0 +1,63 @@ +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; + +const resourcesOf = planData => [ + { + label: 'API requests', + monthly: true, + used: planData.apiCallUsed || planData.apiCallUsedNormalized, + limit: planData.apiCallLimit, + }, + { + label: 'file storage', + used: planData.fileStorageUsed || planData.fileStorageUsedNormalized, + limit: planData.fileStorageLimit, + }, + { + label: 'database storage', + used: planData.dataStorageUsed || planData.dataStorageUsedNormalized, + limit: planData.dataStorageLimit, + }, +]; + +// Decides the Overview usage banner. Apps over a limit without payment are blocked (the +// server answers 402), so the message is about the app stopping, never about waiting. +// Returns null when nothing is close to a limit. +export const getUsageAlert = (planData, blocked = false) => { + if (!planData || planData instanceof Error) { + return blocked ? { level: 'blocked', message: blockedMessage(null) } : null; + } + const resources = resourcesOf(planData) + .map(resource => ({ ...resource, percent: getUsagePercent(resource.used, resource.limit) })) + .filter(resource => resource.percent !== null) + .sort((a, b) => b.percent - a.percent); + const worst = resources[0]; + + if (blocked || (worst && worst.percent >= 100)) { + return { level: 'blocked', message: blockedMessage(worst && worst.percent >= 100 ? worst : null) }; + } + if (!worst || worst.percent <= WARNING_PERCENT) { + return null; + } + + const percent = Math.floor(worst.percent); + const amount = `(${worst.used} of ${worst.limit})`; + const usage = worst.monthly + ? `You've used ${percent}% of this month's ${worst.label} ${amount}.` + : `Your ${worst.label} is ${percent}% full ${amount}.`; + const othersClose = resources.slice(1).filter(resource => resource.percent > WARNING_PERCENT).length; + const others = othersClose + ? ` ${othersClose} other ${othersClose === 1 ? 'limit is' : 'limits are'} close too.` + : ''; + + return { + level: worst.percent > DANGER_PERCENT ? 'danger' : 'warning', + message: `${usage}${others} At 100%, your app stops responding and your users start getting errors.`, + }; +}; + +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/lib/tests/usageAlert.test.js b/src/lib/tests/usageAlert.test.js new file mode 100644 index 0000000000..88f93de30b --- /dev/null +++ b/src/lib/tests/usageAlert.test.js @@ -0,0 +1,57 @@ +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('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('reports a blocked app at 100%', () => { + const alert = getUsageAlert(plan({ 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.' + ); + }); + + 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'); + }); + + 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 498ac6bfe1..0bac923ad4 100644 --- a/src/lib/upgradeEvents.js +++ b/src/lib/upgradeEvents.js @@ -29,6 +29,7 @@ export const UpgradeGate = { HTTPS: 'https', BACKUP_DELETE_CLASS: 'backup_delete_class', BACKUP_DELETE_ROWS: 'backup_delete_rows', + USAGE_LIMIT: 'usage_limit', COMPLIANCE_HIPAA: 'compliance_hipaa', COMPLIANCE_SOC2: 'compliance_soc2', COMPLIANCE_ISO27001: 'compliance_iso27001', From 8a19acbef42176fdab6954c4080c7f2d79385aac Mon Sep 17 00:00:00 2001 From: Alysson Melo Date: Mon, 5 Oct 2026 17:32:51 -0300 Subject: [PATCH 03/17] Jobs limit: Free opens the MVP checkout directly "Job limit reached" sent everyone to the plan comparison page, and the clicks so far did not turn into purchases. On Free the next step is always MVP, so its "Upgrade plan" now opens the MVP checkout ("Schedule more background jobs", up to 3 jobs). Paid plans still go to Plan Usage until upgrades between paid plans are confirmed not to double bill. Co-Authored-By: Claude Opus 5.5 --- .../UpgradeCheckout/UpgradeCheckout.react.js | 2 ++ src/dashboard/Data/Jobs/Jobs.react.js | 19 +++++++++++++++++-- src/lib/tests/UpgradeCheckout.test.js | 6 ++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/components/UpgradeCheckout/UpgradeCheckout.react.js b/src/components/UpgradeCheckout/UpgradeCheckout.react.js index 57b2c01b92..ec0a38a947 100644 --- a/src/components/UpgradeCheckout/UpgradeCheckout.react.js +++ b/src/components/UpgradeCheckout/UpgradeCheckout.react.js @@ -39,6 +39,8 @@ const GATE_OFFERS = { [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, diff --git a/src/dashboard/Data/Jobs/Jobs.react.js b/src/dashboard/Data/Jobs/Jobs.react.js index f2cc6a0522..ff29053522 100644 --- a/src/dashboard/Data/Jobs/Jobs.react.js +++ b/src/dashboard/Data/Jobs/Jobs.react.js @@ -19,6 +19,7 @@ import Popover from 'components/Popover/Popover.react'; import Position from 'lib/Position'; import React from 'react'; import { UpgradeGate, UpgradeGateView, logGateClicked } from 'lib/upgradeEvents'; +import { UpgradeCheckoutModal } from 'components/UpgradeCheckout/UpgradeCheckout.react'; import ReleaseInfo from 'components/ReleaseInfo/ReleaseInfo'; import RunNowButton from 'dashboard/Data/Jobs/RunNowButton.react'; import SidebarAction from 'components/Sidebar/SidebarAction'; @@ -115,6 +116,9 @@ class Jobs extends TableView { // Job limit enforcement jobLimitReached: false, maxJobAmount: undefined, + // Free goes straight to the MVP checkout; paid plans still compare plans on Plan Usage. + isFreePlan: false, + jobsCheckoutOpen: false, }; this.filterWrapRef = React.createRef(); this.JOB_STATUS_PAGE_SIZE = 100; @@ -198,7 +202,10 @@ class Jobs extends TableView { if (this.isScheduledSection(currentSection)) { this.context.getAppPlanData() .then(planData => { - 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/lib/tests/UpgradeCheckout.test.js b/src/lib/tests/UpgradeCheckout.test.js index b805e53fde..a42d11e26c 100644 --- a/src/lib/tests/UpgradeCheckout.test.js +++ b/src/lib/tests/UpgradeCheckout.test.js @@ -197,6 +197,12 @@ 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('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'); From 198dbdde05f4ee0c5af4285c1b93c0f31ddc2453 Mon Sep 17 00:00:00 2001 From: Alysson Melo Date: Mon, 5 Oct 2026 17:35:01 -0300 Subject: [PATCH 04/17] Database Profiler: use the standard direct checkout, yearly first The profiler paywall had its own Paddle checkout that always charged Dedicated monthly ($500) in a plain white modal. "Buy now" now opens the shared direct checkout (yearly preselected, light/dark theme, "See which queries slow your app down"), and the card shows $400/month billed yearly. Removes the duplicated Paddle setup, owner-email lookup and manual event logging (-154 lines); events now come from the shared component, same gate db_profiler. Co-Authored-By: Claude Opus 5.5 --- .../UpgradeCheckout/UpgradeCheckout.react.js | 5 + .../DatabaseProfiler.react.js | 160 +----------------- src/lib/tests/UpgradeCheckout.test.js | 6 + 3 files changed, 17 insertions(+), 154 deletions(-) diff --git a/src/components/UpgradeCheckout/UpgradeCheckout.react.js b/src/components/UpgradeCheckout/UpgradeCheckout.react.js index ec0a38a947..fdc3970717 100644 --- a/src/components/UpgradeCheckout/UpgradeCheckout.react.js +++ b/src/components/UpgradeCheckout/UpgradeCheckout.react.js @@ -65,6 +65,11 @@ const GATE_OFFERS = { 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.', }, + [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.' }, [UpgradeGate.COMPLIANCE_HIPAA]: { plan: 'Dedicated', headline: 'Pass your customer\'s security review', subline: 'HIPAA-ready infrastructure, after a signed BAA.' }, diff --git a/src/dashboard/DatabaseProfiler/DatabaseProfiler.react.js b/src/dashboard/DatabaseProfiler/DatabaseProfiler.react.js index b6e76b90dd..6ab33e613f 100644 --- a/src/dashboard/DatabaseProfiler/DatabaseProfiler.react.js +++ b/src/dashboard/DatabaseProfiler/DatabaseProfiler.react.js @@ -17,12 +17,8 @@ import B4aLoaderContainer from 'components/B4aLoaderContainer/B4aLoaderContainer 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 { 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/lib/tests/UpgradeCheckout.test.js b/src/lib/tests/UpgradeCheckout.test.js index a42d11e26c..79872d7ff1 100644 --- a/src/lib/tests/UpgradeCheckout.test.js +++ b/src/lib/tests/UpgradeCheckout.test.js @@ -203,6 +203,12 @@ describe('UpgradeCheckoutModal', () => { 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('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'); From 4420401bcf0d94d22439783a80e3bda53772060f Mon Sep 17 00:00:00 2001 From: Alysson Melo Date: Mon, 5 Oct 2026 17:40:55 -0300 Subject: [PATCH 05/17] Logs: say how to get older logs, offer a week of access logs on Free The log screens show only the most recent lines and older ones come through support, but nothing said so. Every log screen now ends with "These are the most recent logs. Need older ones? Open a ticket." On Access logs, Free also sees "Free keeps access logs for 1 day; MVP keeps 7 days." (the retention on the pricing page) with an MVP checkout ("Debug with a week of access logs"). New gate: logs_retention. LogsFooter.react.js is added with -f: the repo's "logs/" ignore rule also matches src/dashboard/Data/Logs on case-insensitive file systems. Co-Authored-By: Claude Opus 5.5 --- .../UpgradeCheckout/UpgradeCheckout.react.js | 8 ++- src/dashboard/Data/Logs/AccessLogs.react.js | 2 + src/dashboard/Data/Logs/ErrorLogs.react.js | 2 + src/dashboard/Data/Logs/InfoLogs.react.js | 2 + src/dashboard/Data/Logs/Logs.scss | 24 ++++++++- src/dashboard/Data/Logs/LogsFooter.react.js | 48 +++++++++++++++++ src/dashboard/Data/Logs/SystemLogs.react.js | 2 + src/lib/tests/LogsFooter.test.js | 51 +++++++++++++++++++ src/lib/upgradeEvents.js | 3 ++ 9 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 src/dashboard/Data/Logs/LogsFooter.react.js create mode 100644 src/lib/tests/LogsFooter.test.js diff --git a/src/components/UpgradeCheckout/UpgradeCheckout.react.js b/src/components/UpgradeCheckout/UpgradeCheckout.react.js index fdc3970717..1eec926cab 100644 --- a/src/components/UpgradeCheckout/UpgradeCheckout.react.js +++ b/src/components/UpgradeCheckout/UpgradeCheckout.react.js @@ -6,14 +6,13 @@ 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 } from 'lib/upgradeEvents'; import { Cycle, initPaddle, paddlePlanId, paddlePriceId, recordSubscription } from 'lib/paddleCheckout'; import { prices } from 'dashboard/AppPlan/AppPlan.react'; 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 = () => @@ -65,6 +64,11 @@ const GATE_OFFERS = { 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.', }, + [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', 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/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/upgradeEvents.js b/src/lib/upgradeEvents.js index 0bac923ad4..df50b57f3a 100644 --- a/src/lib/upgradeEvents.js +++ b/src/lib/upgradeEvents.js @@ -30,11 +30,14 @@ export const UpgradeGate = { BACKUP_DELETE_CLASS: 'backup_delete_class', BACKUP_DELETE_ROWS: 'backup_delete_rows', USAGE_LIMIT: 'usage_limit', + LOGS_RETENTION: 'logs_retention', 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'; + export const planUsagePath = (slug, gate) => `/apps/${slug}/plan-usage?gate=${gate}`; export const getGateFromSearch = (search) => { From 1dc6abbd4716cbfeca33da3b50c564ee37c77497 Mon Sep 17 00:00:00 2001 From: Alysson Melo Date: Tue, 6 Oct 2026 08:42:07 -0300 Subject: [PATCH 06/17] Backup notice: dialog row with the green upgrade button, also on column delete The backup notice looked like a separate card inside the delete dialogs (bordered box, different width, plain text link). It is now a regular dialog row, like "Confirm this action": "No backups on Free / Deleted data can't be recovered. MVP keeps a daily backup for 7 days." with the standard green "Get daily backups" button, same as the other paywalls. Removing a column deletes that field in every row, so the "Remove a column?" and "Delete this column?" dialogs get the notice too (new gate backup_delete_column). Co-Authored-By: Claude Opus 5.5 --- .../UpgradeCheckout/BackupUpsell.react.js | 31 ++++++++++--------- .../UpgradeCheckout/UpgradeCheckout.react.js | 1 + .../UpgradeCheckout/UpgradeCheckout.scss | 25 --------------- .../ConfirmDeleteColumnDialog.react.js | 4 ++- .../Data/Browser/RemoveColumnDialog.react.js | 10 ++++-- src/lib/tests/UpgradeCheckout.test.js | 5 +-- src/lib/upgradeEvents.js | 1 + 7 files changed, 32 insertions(+), 45 deletions(-) diff --git a/src/components/UpgradeCheckout/BackupUpsell.react.js b/src/components/UpgradeCheckout/BackupUpsell.react.js index f847a12684..86d1be64c1 100644 --- a/src/components/UpgradeCheckout/BackupUpsell.react.js +++ b/src/components/UpgradeCheckout/BackupUpsell.react.js @@ -1,10 +1,12 @@ 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'; -import styles from './UpgradeCheckout.scss'; // 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); @@ -26,19 +28,20 @@ const BackupUpsell = ({ gate }) => { return null; } return ( -
- - Free apps don't keep backups, so this can't be recovered. MVP keeps a daily backup for 7 days. - - ( - - Get daily backups - - )} - /> -
+ + } + input={ +
+ +
+ } + /> ); }; diff --git a/src/components/UpgradeCheckout/UpgradeCheckout.react.js b/src/components/UpgradeCheckout/UpgradeCheckout.react.js index 1eec926cab..9e537beb23 100644 --- a/src/components/UpgradeCheckout/UpgradeCheckout.react.js +++ b/src/components/UpgradeCheckout/UpgradeCheckout.react.js @@ -57,6 +57,7 @@ const GATE_OFFERS = { // 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, // HTTPS on a custom domain is turned on by support after the upgrade. [UpgradeGate.HTTPS]: { plan: 'Pay As You Go', diff --git a/src/components/UpgradeCheckout/UpgradeCheckout.scss b/src/components/UpgradeCheckout/UpgradeCheckout.scss index afafc40d39..84598b7fb2 100644 --- a/src/components/UpgradeCheckout/UpgradeCheckout.scss +++ b/src/components/UpgradeCheckout/UpgradeCheckout.scss @@ -208,31 +208,6 @@ } } -// Sits inside the (light) delete confirmation modal, not the themed checkout. -.backupUpsell { - @include InterFont; - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - margin: 0 1rem 0.5rem; - padding: 0.75rem 1rem; - border: 1px solid #e2e6ec; - border-radius: 8px; - background: #f5f7fa; - color: #10203a; - font-size: 0.875rem; - line-height: 1.4; - text-align: left; -} - -.backupUpsellLink { - flex-shrink: 0; - color: $cta-green; - font-weight: 600; - white-space: nowrap; -} - .error { padding: 1rem 0; color: var(--uc-text); 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 ( 0; if (hasColumns) { - content = ( + content = [ + , } input={ } - /> - ); + />, + ]; } return ( { it('warns Free apps that deleted data cannot be recovered and offers backups', async () => { const tree = await mountWithPlan('Free Plan'); - expect(textOf(tree)).toContain('this can\'t be recovered'); + 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 () => { - tree.root.findByType('a').props.onClick({ preventDefault: () => {} }); + tree.root.findByType('button').props.onClick({ preventDefault: () => {} }); }); expect(textOf(tree)).toContain('Keep daily backups of your data'); }); diff --git a/src/lib/upgradeEvents.js b/src/lib/upgradeEvents.js index df50b57f3a..5b09c19ca6 100644 --- a/src/lib/upgradeEvents.js +++ b/src/lib/upgradeEvents.js @@ -29,6 +29,7 @@ export const UpgradeGate = { 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', COMPLIANCE_HIPAA: 'compliance_hipaa', From ab4a72c0c746ba005c8b594e7280f8c59071022f Mon Sep 17 00:00:00 2001 From: Alysson Melo Date: Tue, 6 Oct 2026 08:45:16 -0300 Subject: [PATCH 07/17] Usage banner: most urgent limit first, then API, database, files The banner talked about whichever limit had the highest percentage, so a 78% file storage beat API requests at 74%. Now it picks the most urgent level first (blocked, then red above 90%, then yellow above 70%) and, within the same level, prefers API requests (they run out fastest), then database, then file storage. Co-Authored-By: Claude Opus 5.5 --- src/dashboard/Data/AppOverview/usageAlert.js | 30 +++++++++++++++----- src/lib/tests/usageAlert.test.js | 16 +++++++++++ 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/dashboard/Data/AppOverview/usageAlert.js b/src/dashboard/Data/AppOverview/usageAlert.js index e5b41b69a2..5313b28fe4 100644 --- a/src/dashboard/Data/AppOverview/usageAlert.js +++ b/src/dashboard/Data/AppOverview/usageAlert.js @@ -4,6 +4,8 @@ import { getUsagePercent } from '../../AppPlan/usageClassUtils'; const WARNING_PERCENT = 70; const DANGER_PERCENT = 90; +// 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', @@ -11,16 +13,16 @@ const resourcesOf = planData => [ used: planData.apiCallUsed || planData.apiCallUsedNormalized, limit: planData.apiCallLimit, }, - { - label: 'file storage', - used: planData.fileStorageUsed || planData.fileStorageUsedNormalized, - limit: planData.fileStorageLimit, - }, { label: 'database storage', used: planData.dataStorageUsed || planData.dataStorageUsedNormalized, limit: planData.dataStorageLimit, }, + { + label: 'file storage', + used: planData.fileStorageUsed || planData.fileStorageUsedNormalized, + limit: planData.fileStorageLimit, + }, ]; // Decides the Overview usage banner. Apps over a limit without payment are blocked (the @@ -30,10 +32,14 @@ export const getUsageAlert = (planData, blocked = false) => { if (!planData || planData instanceof Error) { return blocked ? { level: 'blocked', message: blockedMessage(null) } : null; } + // Most urgent first (blocked, then red, then yellow); within the same level, list order wins. const resources = resourcesOf(planData) - .map(resource => ({ ...resource, percent: getUsagePercent(resource.used, resource.limit) })) + .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.percent - a.percent); + .sort((a, b) => b.severity - a.severity || a.priority - b.priority); const worst = resources[0]; if (blocked || (worst && worst.percent >= 100)) { @@ -59,5 +65,15 @@ export const getUsageAlert = (planData, blocked = false) => { }; }; +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/lib/tests/usageAlert.test.js b/src/lib/tests/usageAlert.test.js index 88f93de30b..08554c4532 100644 --- a/src/lib/tests/usageAlert.test.js +++ b/src/lib/tests/usageAlert.test.js @@ -24,6 +24,22 @@ describe('getUsageAlert', () => { ); }); + 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'); From d371571a07ffb78afed627e6dbe77d83d62d651a Mon Sep 17 00:00:00 2001 From: Alysson Melo Date: Tue, 6 Oct 2026 08:52:23 -0300 Subject: [PATCH 08/17] Sidebar: usage percentage next to Plan Usage when a limit is close The Overview banner only shows on the Overview, but the sidebar is on every app page. Plan Usage now shows the same limit as the banner (most urgent first, then API, database, files) as a "84%" pill: yellow above 70%, red above 90%, capped at 100%. Hidden otherwise. Each page renders its own sidebar, so the plan is fetched through a 5-minute per-app cache (lib/planDataCache) instead of on every navigation. getMostUrgentUsage is shared with the banner. B4aSidebar accepts a ready badge element per section besides badgeParams. Co-Authored-By: Claude Opus 5.5 --- src/components/Sidebar/B4aSidebar.react.js | 3 +- .../Sidebar/PlanUsageBadge.react.js | 37 ++++++++++ src/components/Sidebar/PlanUsageBadge.scss | 22 ++++++ src/dashboard/DashboardView.react.js | 2 + src/dashboard/Data/AppOverview/usageAlert.js | 30 +++++--- src/lib/planDataCache.js | 20 ++++++ src/lib/tests/PlanUsageBadge.test.js | 68 +++++++++++++++++++ 7 files changed, 171 insertions(+), 11 deletions(-) create mode 100644 src/components/Sidebar/PlanUsageBadge.react.js create mode 100644 src/components/Sidebar/PlanUsageBadge.scss create mode 100644 src/lib/planDataCache.js create mode 100644 src/lib/tests/PlanUsageBadge.test.js 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..d6e54b4e4f --- /dev/null +++ b/src/components/Sidebar/PlanUsageBadge.react.js @@ -0,0 +1,37 @@ +import React, { useContext, useEffect, useState } from 'react'; +import { CurrentApp } from 'context/currentApp'; +import { getCachedPlanData } from 'lib/planDataCache'; +import { getMostUrgentUsage } from 'dashboard/Data/AppOverview/usageAlert'; +import styles from 'components/Sidebar/PlanUsageBadge.scss'; + +// "84%" next to Plan Usage in the sidebar when a limit is close: yellow above 70%, +// red above 90%. Uses the same limit as the Overview banner, so both show one number. +const PlanUsageBadge = () => { + const context = useContext(CurrentApp); + const [usage, setUsage] = useState(null); + + useEffect(() => { + let active = true; + if (context && context.getAppPlanData) { + getCachedPlanData(context) + .then(planData => active && setUsage(getMostUrgentUsage(planData))) + .catch(() => {}); + } + return () => { + active = false; + }; + }, [context && context.applicationId]); + + const worst = usage && usage.worst; + if (!worst || worst.severity === 0) { + return null; + } + const level = worst.severity >= 2 ? styles.danger : styles.warning; + return ( + + {Math.min(100, Math.floor(worst.percent))}% + + ); +}; + +export default PlanUsageBadge; diff --git a/src/components/Sidebar/PlanUsageBadge.scss b/src/components/Sidebar/PlanUsageBadge.scss new file mode 100644 index 0000000000..f239ed858e --- /dev/null +++ b/src/components/Sidebar/PlanUsageBadge.scss @@ -0,0 +1,22 @@ +.badge { + flex-shrink: 0; + margin-left: auto; + 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; +} + +.danger { + border-color: rgba(232, 92, 62, 0.6); + background: rgba(232, 92, 62, 0.12); + color: #E85C3E; +} 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/usageAlert.js b/src/dashboard/Data/AppOverview/usageAlert.js index 5313b28fe4..8ee9cfeedd 100644 --- a/src/dashboard/Data/AppOverview/usageAlert.js +++ b/src/dashboard/Data/AppOverview/usageAlert.js @@ -25,14 +25,13 @@ const resourcesOf = planData => [ }, ]; -// Decides the Overview usage banner. Apps over a limit without payment are blocked (the -// server answers 402), so the message is about the app stopping, never about waiting. -// Returns null when nothing is close to a limit. -export const getUsageAlert = (planData, blocked = false) => { +// The limit to talk about: most urgent first (blocked, 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 blocked ? { level: 'blocked', message: blockedMessage(null) } : null; + return null; } - // Most urgent first (blocked, then red, then yellow); within the same level, list order wins. const resources = resourcesOf(planData) .map((resource, priority) => { const percent = getUsagePercent(resource.used, resource.limit); @@ -40,7 +39,18 @@ export const getUsageAlert = (planData, blocked = false) => { }) .filter(resource => resource.percent !== null) .sort((a, b) => b.severity - a.severity || a.priority - b.priority); - const worst = resources[0]; + return resources.length ? { worst: resources[0], others: resources.slice(1) } : null; +}; + +// Decides the Overview usage banner. Apps over a limit without payment are blocked (the +// server answers 402), so the message is about the app stopping, never about waiting. +// Returns null when nothing is close to a limit. +export const getUsageAlert = (planData, blocked = false) => { + if (!planData || planData instanceof Error) { + return blocked ? { level: 'blocked', message: blockedMessage(null) } : null; + } + const usage = getMostUrgentUsage(planData); + const worst = usage && usage.worst; if (blocked || (worst && worst.percent >= 100)) { return { level: 'blocked', message: blockedMessage(worst && worst.percent >= 100 ? worst : null) }; @@ -51,17 +61,17 @@ export const getUsageAlert = (planData, blocked = false) => { const percent = Math.floor(worst.percent); const amount = `(${worst.used} of ${worst.limit})`; - const usage = worst.monthly + const summary = worst.monthly ? `You've used ${percent}% of this month's ${worst.label} ${amount}.` : `Your ${worst.label} is ${percent}% full ${amount}.`; - const othersClose = resources.slice(1).filter(resource => resource.percent > WARNING_PERCENT).length; + const othersClose = usage.others.filter(resource => resource.percent > WARNING_PERCENT).length; const others = othersClose ? ` ${othersClose} other ${othersClose === 1 ? 'limit is' : 'limits are'} close too.` : ''; return { level: worst.percent > DANGER_PERCENT ? 'danger' : 'warning', - message: `${usage}${others} At 100%, your app stops responding and your users start getting errors.`, + message: `${summary}${others} At 100%, your app stops responding and your users start getting errors.`, }; }; diff --git a/src/lib/planDataCache.js b/src/lib/planDataCache.js new file mode 100644 index 0000000000..e800ba68e5 --- /dev/null +++ b/src/lib/planDataCache.js @@ -0,0 +1,20 @@ +// 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 = 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/PlanUsageBadge.test.js b/src/lib/tests/PlanUsageBadge.test.js new file mode 100644 index 0000000000..05d5ea8db5 --- /dev/null +++ b/src/lib/tests/PlanUsageBadge.test.js @@ -0,0 +1,68 @@ +// 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 => ({ + 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('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); + }); +}); From 5d5b95b2d0f2fde1d9e7234053b605411ac355f1 Mon Sep 17 00:00:00 2001 From: Alysson Melo Date: Tue, 6 Oct 2026 08:56:49 -0300 Subject: [PATCH 09/17] Plan Usage: usage banner on top, MVP recommended on Free - Plan Usage only colored the numbers yellow. It now shows the same usage banner as the Overview above the plan card (same limit, same wording, MVP checkout on Free; no "See plans" link since the user is already there). - On Free, MVP gets the highlight and the green button with a "Recommended for you" ribbon: for a Free app near its limits, MVP ($15/yr-month) already covers it. Paid plans keep Pay As You Go as "Most Popular". - The banner moves to components/UsageLimitBanner (shared by Overview and Plan Usage), and the plan list moves to dashboard/AppPlan/prices.js to avoid an import cycle (AppPlan -> banner -> checkout -> AppPlan). AppPlan still re-exports prices. Co-Authored-By: Claude Opus 5.5 --- .../UpgradeCheckout/UpgradeCheckout.react.js | 2 +- .../UsageLimitBanner.react.js | 33 ++++ .../UsageLimitBanner/UsageLimitBanner.scss | 47 +++++ src/dashboard/AppPlan/AppPlan.react.js | 166 +++--------------- src/dashboard/AppPlan/AppPlan.scss | 4 + src/dashboard/AppPlan/prices.js | 138 +++++++++++++++ .../Data/AppOverview/AppOverview.react.js | 25 +-- .../Data/AppOverview/AppOverview.scss | 45 ----- .../DatabaseProfiler.react.js | 2 +- src/lib/tests/UpgradeCheckout.test.js | 2 +- 10 files changed, 252 insertions(+), 212 deletions(-) create mode 100644 src/components/UsageLimitBanner/UsageLimitBanner.react.js create mode 100644 src/components/UsageLimitBanner/UsageLimitBanner.scss create mode 100644 src/dashboard/AppPlan/prices.js diff --git a/src/components/UpgradeCheckout/UpgradeCheckout.react.js b/src/components/UpgradeCheckout/UpgradeCheckout.react.js index 9e537beb23..157fedff84 100644 --- a/src/components/UpgradeCheckout/UpgradeCheckout.react.js +++ b/src/components/UpgradeCheckout/UpgradeCheckout.react.js @@ -8,7 +8,7 @@ import { CurrentApp } from 'context/currentApp'; import { amplitudeLogEvent } from 'lib/amplitudeEvents'; import { SUPPORT_TICKET_URL, UpgradeEvent, UpgradeGate, UpgradeGateView, logGateClicked, planUsagePath } 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 styles from './UpgradeCheckout.scss'; const CHECKOUT_FRAME_CLASS = 'upgrade-checkout-frame'; diff --git a/src/components/UsageLimitBanner/UsageLimitBanner.react.js b/src/components/UsageLimitBanner/UsageLimitBanner.react.js new file mode 100644 index 0000000000..aaa34e182c --- /dev/null +++ b/src/components/UsageLimitBanner/UsageLimitBanner.react.js @@ -0,0 +1,33 @@ +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). Free opens the MVP checkout; other +// plans 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) { + return null; + } + const isFree = planData && !(planData instanceof Error) && /^free/i.test(planData.planName || ''); + const cta = alert.level === 'blocked' ? 'Upgrade to bring it back' : 'Upgrade to MVP'; + let action = null; + if (isFree) { + action = ; + } else if (showPlansLink) { + action = See plans; + } + return ( +
+ +
{alert.message}
+ {action} +
+ ); +}; + +export default UsageLimitBanner; diff --git a/src/components/UsageLimitBanner/UsageLimitBanner.scss b/src/components/UsageLimitBanner/UsageLimitBanner.scss new file mode 100644 index 0000000000..16eb748dcc --- /dev/null +++ b/src/components/UsageLimitBanner/UsageLimitBanner.scss @@ -0,0 +1,47 @@ +@import 'stylesheets/globals.scss'; +@import 'stylesheets/back4app.scss'; + +// Yellow above 70%, red above 90% or when the app is blocked. +.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; + } +} + +.text { + flex: 1; + min-width: 0; +} + +.link { + flex-shrink: 0; + color: #27AE60; + font-weight: 600; + white-space: nowrap; +} diff --git a/src/dashboard/AppPlan/AppPlan.react.js b/src/dashboard/AppPlan/AppPlan.react.js index de13e76f29..425ed8a547 100644 --- a/src/dashboard/AppPlan/AppPlan.react.js +++ b/src/dashboard/AppPlan/AppPlan.react.js @@ -21,144 +21,9 @@ import { initializePaddle } from '@paddle/paddle-js'; import B4aModal from 'components/B4aModal/B4aModal.react'; import { amplitudeLogEvent } from 'lib/amplitudeEvents'; import { UpgradeEvent, getGateFromSearch } from 'lib/upgradeEvents'; +import UsageLimitBanner from 'components/UsageLimitBanner/UsageLimitBanner.react'; +import { prices } from 'dashboard/AppPlan/prices'; -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 & custom domain', - }, - { - 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 your own domain' - }, - { - 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: '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' - }, -]; @withRouter class AppPlan extends DashboardView { @@ -214,7 +79,9 @@ class AppPlan extends DashboardView { this.context.getAppPlanData().then(res => this.setState({ isLoadingAppPlanData: false, appPlanData: res, - appPlanName: res.planName + appPlanName: res.planName, + // MVP is the natural next step from Free, so it gets the highlight there. + ...(isFreePlan(res) ? { selectedPlan: prices[0] } : {}), })).catch(err => this.setState({ isLoadingAppPlanData: false, appPlanData: new Error(err.message || err.msg || 'Something went wrong') @@ -399,6 +266,14 @@ class AppPlan extends DashboardView { content =
Plan Usage
Track your resource utilization across all features to optimize your app and plan allocation.
+
+ +
{planData.planName}
@@ -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..ed6e44d714 --- /dev/null +++ b/src/dashboard/AppPlan/prices.js @@ -0,0 +1,138 @@ +// 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 & custom domain', + }, + { + 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 your own domain' + }, + { + 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: '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/Data/AppOverview/AppOverview.react.js b/src/dashboard/Data/AppOverview/AppOverview.react.js index ce8a8afdbb..09e4b0caff 100644 --- a/src/dashboard/Data/AppOverview/AppOverview.react.js +++ b/src/dashboard/Data/AppOverview/AppOverview.react.js @@ -20,8 +20,8 @@ import B4aTooltip from 'components/Tooltip/B4aTooltip.react'; import OnboardingBoxes from './OnboardingBoxes.react'; import AccountManager from 'lib/AccountManager'; import { amplitudeLogEvent } from 'lib/amplitudeEvents'; -import { UpgradeGate, planUsagePath } from 'lib/upgradeEvents'; -import { getUsageAlert } from './usageAlert'; +import { UpgradeGate } from 'lib/upgradeEvents'; +import UsageLimitBanner from 'components/UsageLimitBanner/UsageLimitBanner.react'; import { UpgradeGateButton } from 'components/UpgradeCheckout/UpgradeCheckout.react'; import AppOverviewActions from './AppOverviewActions.react'; import ComplianceCard from './ComplianceCard.react'; @@ -52,27 +52,6 @@ const CopyValue = ({ value }) => { // One column of the facts row: small label, the value, and an optional action // or note under it. // A missing value shows as a dash rather than an empty column. -// Near or over a plan limit. Free opens the MVP checkout; other plans open Plan Usage. -const UsageLimitBanner = ({ planData, blocked, slug }) => { - const alert = getUsageAlert(planData, blocked); - if (!alert) { - return null; - } - const isFree = planData && !(planData instanceof Error) && /^free/i.test(planData.planName || ''); - const cta = alert.level === 'blocked' ? 'Upgrade to bring it back' : 'Upgrade to MVP'; - return ( -
- -
{alert.message}
- {isFree ? ( - - ) : ( - See plans - )} -
- ); -}; - // The app's plan next to its name. Free opens the MVP checkout; other plans open Plan Usage. const PlanBadge = ({ planData, slug }) => { if (!planData || planData instanceof Error || !planData.planName) { diff --git a/src/dashboard/Data/AppOverview/AppOverview.scss b/src/dashboard/Data/AppOverview/AppOverview.scss index 8758ddf9d6..35ff7feeef 100644 --- a/src/dashboard/Data/AppOverview/AppOverview.scss +++ b/src/dashboard/Data/AppOverview/AppOverview.scss @@ -157,51 +157,6 @@ white-space: nowrap; } - // Usage banner above the app summary: yellow above 70%, red above 90% or when blocked. - & .usageBanner { - 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; - } - - & .usageBanner_warning { - border-color: rgba(251, 255, 59, 0.5); - background: rgba(251, 255, 59, 0.06); - - svg { - color: #FBFF3B; - } - } - - & .usageBanner_danger, - & .usageBanner_blocked { - border-color: rgba(232, 92, 62, 0.6); - background: rgba(232, 92, 62, 0.1); - - svg { - color: #E85C3E; - } - } - - & .usageBannerText { - flex: 1; - min-width: 0; - } - - & .usageBannerLink { - flex-shrink: 0; - color: #27AE60; - font-weight: 600; - white-space: nowrap; - } - & .appSummaryNameRow { display: flex; align-items: center; diff --git a/src/dashboard/DatabaseProfiler/DatabaseProfiler.react.js b/src/dashboard/DatabaseProfiler/DatabaseProfiler.react.js index 6ab33e613f..2dc9ba61a5 100644 --- a/src/dashboard/DatabaseProfiler/DatabaseProfiler.react.js +++ b/src/dashboard/DatabaseProfiler/DatabaseProfiler.react.js @@ -16,7 +16,7 @@ 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 { prices } from 'dashboard/AppPlan/prices'; import { UpgradeGate } from 'lib/upgradeEvents'; import { UpgradeGateButton } from 'components/UpgradeCheckout/UpgradeCheckout.react'; diff --git a/src/lib/tests/UpgradeCheckout.test.js b/src/lib/tests/UpgradeCheckout.test.js index 9379e85322..5c22fd195c 100644 --- a/src/lib/tests/UpgradeCheckout.test.js +++ b/src/lib/tests/UpgradeCheckout.test.js @@ -12,7 +12,7 @@ jest.mock('../../components/Popover/Popover.react', () => { }); jest.mock('context/currentApp', () => ({ CurrentApp: require('react').createContext(null) }), { virtual: true }); jest.mock( - 'dashboard/AppPlan/AppPlan.react', + 'dashboard/AppPlan/prices', () => ({ prices: [ { From f92cf2e0689a5bb53890a195df085206cd9dc272 Mon Sep 17 00:00:00 2001 From: Alysson Melo Date: Tue, 6 Oct 2026 10:17:27 -0300 Subject: [PATCH 10/17] Sidebar usage badge: right-aligned on unselected rows, no hover underline Co-Authored-By: Claude Opus 5.5 --- src/components/Sidebar/PlanUsageBadge.scss | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/components/Sidebar/PlanUsageBadge.scss b/src/components/Sidebar/PlanUsageBadge.scss index f239ed858e..e93cf893dc 100644 --- a/src/components/Sidebar/PlanUsageBadge.scss +++ b/src/components/Sidebar/PlanUsageBadge.scss @@ -1,6 +1,13 @@ +// 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; From ec552b8bca82167bf89bb1f2fc1f3dbf517df944 Mon Sep 17 00:00:00 2001 From: Alysson Melo Date: Tue, 6 Oct 2026 10:36:48 -0300 Subject: [PATCH 11/17] Hosting region: on Free, paid regions open the MVP checkout "Change" sent every app to the migration Typeform, where Free users pick South Korea, India, Australia or Singapore only to learn they need a paid plan. On Free it now opens a region picker in the dashboard: - Europe (free) goes to the migration form, as before - the other regions open the MVP checkout ("Run your app closer to your users"); after paying, the checkout points to the same form for this app ("Request the migration") Paid apps still go straight to the form. New gate: region_change. The checkout's post-purchase step now takes any link (nextStepLink); HTTPS keeps "Open a ticket". Co-Authored-By: Claude Opus 5.5 --- .../UpgradeCheckout/UpgradeCheckout.react.js | 13 ++- .../Data/AppOverview/AppOverview.react.js | 4 +- .../Data/AppOverview/RegionChange.react.js | 87 +++++++++++++++++++ .../Data/AppOverview/RegionChange.scss | 55 ++++++++++++ src/lib/tests/RegionChange.test.js | 76 ++++++++++++++++ src/lib/tests/UpgradeCheckout.test.js | 10 +++ src/lib/upgradeEvents.js | 4 + 7 files changed, 246 insertions(+), 3 deletions(-) create mode 100644 src/dashboard/Data/AppOverview/RegionChange.react.js create mode 100644 src/dashboard/Data/AppOverview/RegionChange.scss create mode 100644 src/lib/tests/RegionChange.test.js diff --git a/src/components/UpgradeCheckout/UpgradeCheckout.react.js b/src/components/UpgradeCheckout/UpgradeCheckout.react.js index 157fedff84..7e7563307f 100644 --- a/src/components/UpgradeCheckout/UpgradeCheckout.react.js +++ b/src/components/UpgradeCheckout/UpgradeCheckout.react.js @@ -6,7 +6,7 @@ import Icon from 'components/Icon/Icon.react'; import AccountManager from 'lib/AccountManager'; import { CurrentApp } from 'context/currentApp'; import { amplitudeLogEvent } from 'lib/amplitudeEvents'; -import { SUPPORT_TICKET_URL, 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/prices'; import styles from './UpgradeCheckout.scss'; @@ -58,12 +58,21 @@ const GATE_OFFERS = { [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', @@ -230,7 +239,7 @@ export const UpgradeCheckoutModal = ({ gate, onClose }) => { {offer.nextStep ? (
{offer.nextStep}{' '} - Open a ticket + {offer.nextStepLink.label}
) : null}
diff --git a/src/dashboard/Data/AppOverview/AppOverview.react.js b/src/dashboard/Data/AppOverview/AppOverview.react.js index 09e4b0caff..ec03202665 100644 --- a/src/dashboard/Data/AppOverview/AppOverview.react.js +++ b/src/dashboard/Data/AppOverview/AppOverview.react.js @@ -22,6 +22,7 @@ import AccountManager from 'lib/AccountManager'; import { amplitudeLogEvent } from 'lib/amplitudeEvents'; import { UpgradeGate } 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'; @@ -266,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; @@ -330,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} 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 => ( + + ))} +
+
+ ) : 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/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 5c22fd195c..23955684e1 100644 --- a/src/lib/tests/UpgradeCheckout.test.js +++ b/src/lib/tests/UpgradeCheckout.test.js @@ -209,6 +209,16 @@ describe('UpgradeCheckoutModal', () => { 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('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'); diff --git a/src/lib/upgradeEvents.js b/src/lib/upgradeEvents.js index 5b09c19ca6..69977227ee 100644 --- a/src/lib/upgradeEvents.js +++ b/src/lib/upgradeEvents.js @@ -32,6 +32,7 @@ export const UpgradeGate = { 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', @@ -39,6 +40,9 @@ export const UpgradeGate = { 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) => { From 5c81c266682c61f6453608570b72d6cda9becdbd Mon Sep 17 00:00:00 2001 From: Alysson Melo Date: Tue, 6 Oct 2026 11:31:27 -0300 Subject: [PATCH 12/17] Usage warnings by plan: MVP yellow with overage prices, Pay As You Go only over quota Paid plans keep running over their limits and pay the overage, so "your app stops responding" was wrong for them: - Free: unchanged (yellow >70%, red >90%, blocked at 100%). - MVP: always yellow, with the overage price from the pricing page. "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." Past 100%: "It keeps running, and extra requests are billed at $5 per 100K." Button: Upgrade plan (Plan Usage). - Pay As You Go: nothing until over its included usage, then a neutral note that the overage is billed ($2 per 100K requests, $15/GB database, $1 per 10 GB files) with "Upgrade to Dedicated". - Dedicated / custom: no usage warnings. - A real 402 from the server is red on any plan. The sidebar pill follows the same rules and shows the real percentage past 100% on paid plans. Co-Authored-By: Claude Opus 5.5 --- .../Sidebar/PlanUsageBadge.react.js | 20 +-- src/components/Sidebar/PlanUsageBadge.scss | 6 + .../UsageLimitBanner.react.js | 13 +- .../UsageLimitBanner/UsageLimitBanner.scss | 12 +- src/dashboard/Data/AppOverview/usageAlert.js | 123 ++++++++++++++---- src/lib/tests/PlanUsageBadge.test.js | 23 ++++ src/lib/tests/usageAlert.test.js | 53 ++++++++ 7 files changed, 207 insertions(+), 43 deletions(-) diff --git a/src/components/Sidebar/PlanUsageBadge.react.js b/src/components/Sidebar/PlanUsageBadge.react.js index d6e54b4e4f..6900239b62 100644 --- a/src/components/Sidebar/PlanUsageBadge.react.js +++ b/src/components/Sidebar/PlanUsageBadge.react.js @@ -1,20 +1,20 @@ import React, { useContext, useEffect, useState } from 'react'; import { CurrentApp } from 'context/currentApp'; import { getCachedPlanData } from 'lib/planDataCache'; -import { getMostUrgentUsage } from 'dashboard/Data/AppOverview/usageAlert'; +import { getUsageBadge } from 'dashboard/Data/AppOverview/usageAlert'; import styles from 'components/Sidebar/PlanUsageBadge.scss'; -// "84%" next to Plan Usage in the sidebar when a limit is close: yellow above 70%, -// red above 90%. Uses the same limit as the Overview banner, so both show one number. +// "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. const PlanUsageBadge = () => { const context = useContext(CurrentApp); - const [usage, setUsage] = useState(null); + const [badge, setBadge] = useState(null); useEffect(() => { let active = true; if (context && context.getAppPlanData) { getCachedPlanData(context) - .then(planData => active && setUsage(getMostUrgentUsage(planData))) + .then(planData => active && setBadge(getUsageBadge(planData))) .catch(() => {}); } return () => { @@ -22,16 +22,10 @@ const PlanUsageBadge = () => { }; }, [context && context.applicationId]); - const worst = usage && usage.worst; - if (!worst || worst.severity === 0) { + if (!badge) { return null; } - const level = worst.severity >= 2 ? styles.danger : styles.warning; - return ( - - {Math.min(100, Math.floor(worst.percent))}% - - ); + return {badge.percent}%; }; export default PlanUsageBadge; diff --git a/src/components/Sidebar/PlanUsageBadge.scss b/src/components/Sidebar/PlanUsageBadge.scss index e93cf893dc..6b6bcb1488 100644 --- a/src/components/Sidebar/PlanUsageBadge.scss +++ b/src/components/Sidebar/PlanUsageBadge.scss @@ -22,6 +22,12 @@ 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/UsageLimitBanner/UsageLimitBanner.react.js b/src/components/UsageLimitBanner/UsageLimitBanner.react.js index aaa34e182c..268fd24fe5 100644 --- a/src/components/UsageLimitBanner/UsageLimitBanner.react.js +++ b/src/components/UsageLimitBanner/UsageLimitBanner.react.js @@ -6,20 +6,19 @@ import { UpgradeGateButton } from 'components/UpgradeCheckout/UpgradeCheckout.re import { getUsageAlert } from 'dashboard/Data/AppOverview/usageAlert'; import styles from 'components/UsageLimitBanner/UsageLimitBanner.scss'; -// Near or over a plan limit (Overview and Plan Usage). Free opens the MVP checkout; other -// plans get a link to Plan Usage, unless they are already on it (showPlansLink={false}). +// 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, unless they +// are already on it (showPlansLink={false}). const UsageLimitBanner = ({ planData, blocked, slug, showPlansLink = true }) => { const alert = getUsageAlert(planData, blocked); if (!alert) { return null; } - const isFree = planData && !(planData instanceof Error) && /^free/i.test(planData.planName || ''); - const cta = alert.level === 'blocked' ? 'Upgrade to bring it back' : 'Upgrade to MVP'; let action = null; - if (isFree) { - action = ; + if (alert.action === 'checkout') { + action = ; } else if (showPlansLink) { - action = See plans; + action = {alert.actionLabel}; } return (
diff --git a/src/components/UsageLimitBanner/UsageLimitBanner.scss b/src/components/UsageLimitBanner/UsageLimitBanner.scss index 16eb748dcc..66510c0e5f 100644 --- a/src/components/UsageLimitBanner/UsageLimitBanner.scss +++ b/src/components/UsageLimitBanner/UsageLimitBanner.scss @@ -1,7 +1,8 @@ @import 'stylesheets/globals.scss'; @import 'stylesheets/back4app.scss'; -// Yellow above 70%, red above 90% or when the app is blocked. +// 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; @@ -34,6 +35,15 @@ } } +.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/usageAlert.js b/src/dashboard/Data/AppOverview/usageAlert.js index 8ee9cfeedd..36d1697c70 100644 --- a/src/dashboard/Data/AppOverview/usageAlert.js +++ b/src/dashboard/Data/AppOverview/usageAlert.js @@ -4,6 +4,9 @@ import { getUsagePercent } from '../../AppPlan/usageClassUtils'; const WARNING_PERCENT = 70; const DANGER_PERCENT = 90; +// Over-quota prices from the pricing page FAQ. Requests depend on the plan. +const EXTRA_REQUESTS_PRICE = { mvp: '$5', payg: '$2' }; + // Listed by priority when two limits are equally urgent: API requests run out first, // then the database, then file storage. const resourcesOf = planData => [ @@ -12,20 +15,39 @@ const resourcesOf = planData => [ monthly: true, used: planData.apiCallUsed || planData.apiCallUsedNormalized, limit: planData.apiCallLimit, + overage: kind => `extra requests are billed at ${EXTRA_REQUESTS_PRICE[kind]} 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', }, ]; -// The limit to talk about: most urgent first (blocked, then red, then yellow) and, within +// Free is blocked over a limit; MVP and Pay As You Go keep running and pay the overage. +// Dedicated and custom plans get no usage warnings. +export const planKindOf = planData => { + const name = (planData && !(planData instanceof Error) && planData.planName) || ''; + if (/^free/i.test(name)) { + return 'free'; + } + if (/^mvp/i.test(name)) { + return 'mvp'; + } + if (/pay\s*as\s*you\s*go/i.test(name)) { + return 'payg'; + } + return 'other'; +}; + +// 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 => { @@ -42,37 +64,94 @@ export const getMostUrgentUsage = planData => { return resources.length ? { worst: resources[0], others: resources.slice(1) } : null; }; -// Decides the Overview usage banner. Apps over a limit without payment are blocked (the -// server answers 402), so the message is about the app stopping, never about waiting. -// Returns null when nothing is close to a limit. +// 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. +// A 402 from the server means the app is really blocked, on any plan. export const getUsageAlert = (planData, blocked = false) => { - if (!planData || planData instanceof Error) { - return blocked ? { level: 'blocked', message: blockedMessage(null) } : null; - } + const kind = planKindOf(planData); const usage = getMostUrgentUsage(planData); const worst = usage && usage.worst; - if (blocked || (worst && worst.percent >= 100)) { - return { level: 'blocked', message: blockedMessage(worst && worst.percent >= 100 ? worst : null) }; + if (blocked || (kind === 'free' && worst && worst.percent >= 100)) { + return { + level: 'blocked', + message: blockedMessage(worst && worst.percent >= 100 ? worst : null), + action: kind === 'free' ? 'checkout' : 'plans', + actionLabel: kind === 'free' ? 'Upgrade to bring it back' : 'Upgrade plan', + }; } - if (!worst || worst.percent <= WARNING_PERCENT) { + if (!worst) { return null; } const percent = Math.floor(worst.percent); const amount = `(${worst.used} of ${worst.limit})`; - const summary = worst.monthly - ? `You've used ${percent}% of this month's ${worst.label} ${amount}.` - : `Your ${worst.label} is ${percent}% full ${amount}.`; - const othersClose = usage.others.filter(resource => resource.percent > WARNING_PERCENT).length; - const others = othersClose - ? ` ${othersClose} other ${othersClose === 1 ? 'limit is' : 'limits are'} close too.` - : ''; - - return { - level: worst.percent > DANGER_PERCENT ? 'danger' : 'warning', - message: `${summary}${others} At 100%, your app stops responding and your users start getting errors.`, - }; + + if (kind === 'free') { + if (worst.percent <= WARNING_PERCENT) { + return null; + } + 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', + }; + } + + // MVP keeps running over its limits and pays the overage: a yellow heads-up, never red. + if (kind === 'mvp') { + if (worst.percent <= WARNING_PERCENT) { + return null; + } + const message = worst.percent >= 100 + ? `This app used ${percent}% of its MVP ${worst.label} ${amount}. It keeps running, and ${worst.overage(kind)}.` + : `${usageSummary(worst, percent, amount, ' on MVP')}${othersClose(usage)} Above 100%, ${worst.overage(kind)}.`; + return { level: 'warning', message, action: 'plans', actionLabel: 'Upgrade plan' }; + } + + // 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 Pay As You Go ${amount}, so ${worst.overage(kind)}.`, + action: 'plans', + actionLabel: 'Upgrade to Dedicated', + }; + } + 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 === '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 => { diff --git a/src/lib/tests/PlanUsageBadge.test.js b/src/lib/tests/PlanUsageBadge.test.js index 05d5ea8db5..182509bb80 100644 --- a/src/lib/tests/PlanUsageBadge.test.js +++ b/src/lib/tests/PlanUsageBadge.test.js @@ -10,6 +10,7 @@ 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', @@ -49,6 +50,28 @@ describe('PlanUsageBadge', () => { }); }); +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('Dedicated shows nothing', async () => { + expect(await mount(plan({ planName: 'Dedicated Plan', dataStorageUsed: '7.9 GB', dataStorageLimit: '8 GB' }))).toBe(null); + }); +}); + describe('getCachedPlanData', () => { beforeEach(() => clearPlanDataCache()); diff --git a/src/lib/tests/usageAlert.test.js b/src/lib/tests/usageAlert.test.js index 08554c4532..d62a2f2846 100644 --- a/src/lib/tests/usageAlert.test.js +++ b/src/lib/tests/usageAlert.test.js @@ -66,6 +66,59 @@ describe('getUsageAlert', () => { 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 plan'); + }); + + 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'); + }); + }); + + it('Dedicated gets no usage warnings', () => { + expect(getUsageAlert(plan({ planName: 'Dedicated Plan', dataStorageUsed: '7.9 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); From 2c23bea8a24d114ddfb5d827661178caaf91f1b7 Mon Sep 17 00:00:00 2001 From: Alysson Melo Date: Tue, 6 Oct 2026 14:14:46 -0300 Subject: [PATCH 13/17] Custom domains start on Pay As You Go: stop selling them on MVP The backend only grants canChangeCustomDomain from Pay As You Go up (Gold, Silver, Dedicated, Custom), but the dashboard sold custom domains on MVP, so an MVP buyer would stay blocked. Two steps now: - Free -> MVP: web hosting on a b4a.app subdomain (copy no longer mentions your own domain: web hosting headline, MVP card, "Take this app to production", Plan Usage upgrade box). - MVP -> Pay As You Go: custom domain with HTTPS. The Domains custom domain paywall and the Overview "Enable HTTPS" link to Plan Usage (Pay As You Go highlighted) instead of opening a second subscription for someone already paying for MVP. The Pay As You Go card lists "Custom domain". The custom_domain checkout offer is Pay As You Go ("Use your own domain"). Co-Authored-By: Claude Opus 5.5 --- .../UpgradeCheckout/UpgradeCheckout.react.js | 13 ++++++--- src/dashboard/AppPlan/AppPlan.react.js | 6 ++--- src/dashboard/AppPlan/prices.js | 7 +++-- .../Data/AppOverview/AppOverview.react.js | 17 +++++++----- .../DomainSettings/DomainSettings.react.js | 14 +++++++--- src/lib/tests/UpgradeCheckout.test.js | 27 ++++++++++++------- 6 files changed, 56 insertions(+), 28 deletions(-) diff --git a/src/components/UpgradeCheckout/UpgradeCheckout.react.js b/src/components/UpgradeCheckout/UpgradeCheckout.react.js index 7e7563307f..e773e28d30 100644 --- a/src/components/UpgradeCheckout/UpgradeCheckout.react.js +++ b/src/components/UpgradeCheckout/UpgradeCheckout.react.js @@ -21,7 +21,7 @@ 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 = { @@ -32,9 +32,14 @@ const BACKUPS = { // 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' }, diff --git a/src/dashboard/AppPlan/AppPlan.react.js b/src/dashboard/AppPlan/AppPlan.react.js index 425ed8a547..35d09ea196 100644 --- a/src/dashboard/AppPlan/AppPlan.react.js +++ b/src/dashboard/AppPlan/AppPlan.react.js @@ -325,16 +325,16 @@ class AppPlan extends DashboardView {
Ready to Scale?
-
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 diff --git a/src/dashboard/AppPlan/prices.js b/src/dashboard/AppPlan/prices.js index ed6e44d714..c32db452da 100644 --- a/src/dashboard/AppPlan/prices.js +++ b/src/dashboard/AppPlan/prices.js @@ -14,7 +14,7 @@ export const prices = [ savePercent: '40%', details: [ { - text: 'Web hosting & custom domain', + text: 'Web hosting on a b4a.app subdomain', }, { text: 'Daily Backups', @@ -47,7 +47,7 @@ export const prices = [ }, ], icon: 'b4a-mvp-plan-icon', - greenText: 'Go live with your own domain' + greenText: 'Go live with web hosting' }, { id: 1, @@ -81,6 +81,9 @@ export const prices = [ { text: 'Daily Backups', }, + { + text: 'Custom domain', + }, { text: 'HTTPS on your custom domain', }, diff --git a/src/dashboard/Data/AppOverview/AppOverview.react.js b/src/dashboard/Data/AppOverview/AppOverview.react.js index ec03202665..1b2bae7331 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 } 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'; @@ -275,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 ( @@ -344,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/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={
} diff --git a/src/lib/tests/UpgradeCheckout.test.js b/src/lib/tests/UpgradeCheckout.test.js index 23955684e1..37f8ab3d24 100644 --- a/src/lib/tests/UpgradeCheckout.test.js +++ b/src/lib/tests/UpgradeCheckout.test.js @@ -18,7 +18,7 @@ jest.mock( { 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%', @@ -84,7 +84,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]; @@ -94,16 +94,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'); @@ -117,7 +117,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'); }); @@ -157,12 +157,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'); @@ -172,7 +172,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 () => { @@ -219,6 +219,13 @@ describe('UpgradeCheckoutModal', () => { 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'); From b194f445683cdcb5e0332a5078cb4b9fe32ad8b0 Mon Sep 17 00:00:00 2001 From: Alysson Melo Date: Tue, 6 Oct 2026 14:25:40 -0300 Subject: [PATCH 14/17] Direct checkout only for Free; paid plans go to Plan Usage Paywalls follow the backend permissions, so they also show for paying apps (e.g. Starter/Solo have no collaborators; Resource-Based, Partners or Public App have none of the paid features). Their button opened the MVP checkout anyway, which starts a second subscription, sometimes for a plan below the current one. UpgradeGateButton now checks the plan (5-minute per-app cache) when clicked: Free opens the direct checkout as before; any other plan, or a failed plan lookup, goes to Plan Usage with ?gate=. The click event is logged in both cases. Co-Authored-By: Claude Opus 5.5 --- .../UpgradeCheckout/UpgradeCheckout.react.js | 18 ++++++++-- src/lib/planDataCache.js | 10 +++--- src/lib/tests/UpgradeCheckout.test.js | 36 +++++++++++++++++-- 3 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/components/UpgradeCheckout/UpgradeCheckout.react.js b/src/components/UpgradeCheckout/UpgradeCheckout.react.js index e773e28d30..230ea87c8e 100644 --- a/src/components/UpgradeCheckout/UpgradeCheckout.react.js +++ b/src/components/UpgradeCheckout/UpgradeCheckout.react.js @@ -9,6 +9,8 @@ import { amplitudeLogEvent } from 'lib/amplitudeEvents'; 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/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'; @@ -267,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); @@ -277,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 ( diff --git a/src/lib/planDataCache.js b/src/lib/planDataCache.js index e800ba68e5..b1839f75c7 100644 --- a/src/lib/planDataCache.js +++ b/src/lib/planDataCache.js @@ -9,10 +9,12 @@ export const getCachedPlanData = app => { if (hit && Date.now() - hit.at < TTL_MS) { return hit.promise; } - const promise = app.getAppPlanData().catch(error => { - cache.delete(key); - throw error; - }); + const promise = Promise.resolve() + .then(() => app.getAppPlanData()) + .catch(error => { + cache.delete(key); + throw error; + }); cache.set(key, { promise, at: Date.now() }); return promise; }; diff --git a/src/lib/tests/UpgradeCheckout.test.js b/src/lib/tests/UpgradeCheckout.test.js index 37f8ab3d24..caa36ffc73 100644 --- a/src/lib/tests/UpgradeCheckout.test.js +++ b/src/lib/tests/UpgradeCheckout.test.js @@ -11,6 +11,7 @@ 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/prices', () => ({ @@ -43,7 +44,15 @@ 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() } }); @@ -238,6 +247,8 @@ describe('UpgradeCheckoutModal', () => { describe('UpgradeGateButton', () => { beforeEach(() => { + clearPlanDataCache(); + app = appOnPlan('Free Plan'); amplitudeLogEvent.mockClear(); initPaddle.mockReset(); initPaddle.mockImplementation(() => Promise.resolve(fakePaddle())); @@ -252,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(); @@ -261,9 +272,28 @@ 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 }) }; @@ -291,7 +321,7 @@ describe('BackupUpsell', () => { expect(loggedEvents('baas_upgrade_gate_viewed')).toEqual([{ gate: 'backup_delete_class', app_id: 'app-1' }]); await renderer.act(async () => { - tree.root.findByType('button').props.onClick({ preventDefault: () => {} }); + await tree.root.findByType('button').props.onClick({ preventDefault: () => {} }); }); expect(textOf(tree)).toContain('Keep daily backups of your data'); }); From 7f03e5a708b28cd480b73bcd0ba45fa7b54cd6ba Mon Sep 17 00:00:00 2001 From: Alysson Melo Date: Tue, 6 Oct 2026 14:35:36 -0300 Subject: [PATCH 15/17] Free over its limit: "can stop at any moment" until the plan is paused A Free app at 100% still serves requests until it is paused, so "Your app stopped responding" was not true yet (suggested by Charles). Two states: - over the limit, still running: "Your app reached its API requests limit and can stop responding at any moment. Upgrade now to keep it running." (red, "Upgrade to keep it running") - blocked, when the plan is "Free Plan - Paused" or the server answers 402: the existing "stopped responding" message ("Upgrade to bring it back"). Co-Authored-By: Claude Opus 5.5 --- src/dashboard/Data/AppOverview/usageAlert.js | 15 +++++++++++++-- src/lib/tests/usageAlert.test.js | 13 ++++++++++++- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/dashboard/Data/AppOverview/usageAlert.js b/src/dashboard/Data/AppOverview/usageAlert.js index 36d1697c70..d821826195 100644 --- a/src/dashboard/Data/AppOverview/usageAlert.js +++ b/src/dashboard/Data/AppOverview/usageAlert.js @@ -66,13 +66,15 @@ export const getMostUrgentUsage = 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. -// A 402 from the server means the app is really blocked, on any plan. +// 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((planData && !(planData instanceof Error) && planData.planName) || ''); - if (blocked || (kind === 'free' && worst && worst.percent >= 100)) { + if (blocked || paused) { return { level: 'blocked', message: blockedMessage(worst && worst.percent >= 100 ? worst : null), @@ -91,6 +93,15 @@ export const getUsageAlert = (planData, blocked = false) => { 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.`, diff --git a/src/lib/tests/usageAlert.test.js b/src/lib/tests/usageAlert.test.js index d62a2f2846..8e8b5b7f89 100644 --- a/src/lib/tests/usageAlert.test.js +++ b/src/lib/tests/usageAlert.test.js @@ -53,12 +53,23 @@ describe('getUsageAlert', () => { expect(alert.message).not.toMatch(/until/i); }); - it('reports a blocked app at 100%', () => { + 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', () => { From 3876e67b59bb6aca164c0be5344295a3954f575f Mon Sep 17 00:00:00 2001 From: charles-ramos Date: Tue, 6 Oct 2026 15:06:01 -0300 Subject: [PATCH 16/17] Usage warnings on legacy plans: each one points at the next plan up Solo and Starter offer MVP; MVP, Basic, Intermediate and Standard offer Pay As You Go; Pay As You Go and Advanced offer Dedicated. Dedicated, Silver, Gold, Platinum and custom plans stay without usage warnings. Legacy plans show no overage price, since only MVP and Pay As You Go publish one. Co-Authored-By: Claude Opus 5.5 --- .../Sidebar/PlanUsageBadge.react.js | 1 + .../UsageLimitBanner.react.js | 4 +- src/dashboard/Data/AppOverview/usageAlert.js | 56 +++++++++++++------ src/lib/tests/PlanUsageBadge.test.js | 9 +++ src/lib/tests/usageAlert.test.js | 50 ++++++++++++++++- 5 files changed, 99 insertions(+), 21 deletions(-) diff --git a/src/components/Sidebar/PlanUsageBadge.react.js b/src/components/Sidebar/PlanUsageBadge.react.js index 6900239b62..d24ffea607 100644 --- a/src/components/Sidebar/PlanUsageBadge.react.js +++ b/src/components/Sidebar/PlanUsageBadge.react.js @@ -6,6 +6,7 @@ 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); diff --git a/src/components/UsageLimitBanner/UsageLimitBanner.react.js b/src/components/UsageLimitBanner/UsageLimitBanner.react.js index 268fd24fe5..0865242fb5 100644 --- a/src/components/UsageLimitBanner/UsageLimitBanner.react.js +++ b/src/components/UsageLimitBanner/UsageLimitBanner.react.js @@ -7,8 +7,8 @@ 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, unless they -// are already on it (showPlansLink={false}). +// 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) { diff --git a/src/dashboard/Data/AppOverview/usageAlert.js b/src/dashboard/Data/AppOverview/usageAlert.js index d821826195..8ba5a96271 100644 --- a/src/dashboard/Data/AppOverview/usageAlert.js +++ b/src/dashboard/Data/AppOverview/usageAlert.js @@ -4,8 +4,12 @@ import { getUsagePercent } from '../../AppPlan/usageClassUtils'; const WARNING_PERCENT = 70; const DANGER_PERCENT = 90; -// Over-quota prices from the pricing page FAQ. Requests depend on the plan. -const EXTRA_REQUESTS_PRICE = { mvp: '$5', payg: '$2' }; +// Over-quota prices from the pricing page FAQ. Requests depend on the plan. Only the current +// MVP and Pay As You Go publish them: legacy plans get the warning without a price. +const EXTRA_REQUESTS_PRICE = { MVP: '$5', 'Pay As You Go': '$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. @@ -15,7 +19,7 @@ const resourcesOf = planData => [ monthly: true, used: planData.apiCallUsed || planData.apiCallUsedNormalized, limit: planData.apiCallLimit, - overage: kind => `extra requests are billed at ${EXTRA_REQUESTS_PRICE[kind]} per 100K`, + overage: plan => `extra requests are billed at ${EXTRA_REQUESTS_PRICE[plan]} per 100K`, }, { label: 'database storage', @@ -31,22 +35,38 @@ 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. -// Dedicated and custom plans get no usage warnings. +// 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 = (planData && !(planData instanceof Error) && planData.planName) || ''; + const name = planNameOf(planData); if (/^free/i.test(name)) { return 'free'; } - if (/^mvp/i.test(name)) { + if (/^(solo|starter)/i.test(name)) { + return 'starter'; + } + if (/^(mvp|basic|intermediate|standard)/i.test(name)) { return 'mvp'; } - if (/pay\s*as\s*you\s*go/i.test(name)) { + 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. @@ -72,14 +92,15 @@ export const getUsageAlert = (planData, blocked = false) => { const kind = planKindOf(planData); const usage = getMostUrgentUsage(planData); const worst = usage && usage.worst; - const paused = /paused/i.test((planData && !(planData instanceof Error) && planData.planName) || ''); + 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' : 'Upgrade plan', + actionLabel: kind === 'free' ? 'Upgrade to bring it back' : upgradeLabel, }; } if (!worst) { @@ -110,24 +131,27 @@ export const getUsageAlert = (planData, blocked = false) => { }; } + const plan = planLabelOf(planData); + // MVP keeps running over its limits and pays the overage: a yellow heads-up, never red. - if (kind === 'mvp') { + // 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 MVP ${worst.label} ${amount}. It keeps running, and ${worst.overage(kind)}.` - : `${usageSummary(worst, percent, amount, ' on MVP')}${othersClose(usage)} Above 100%, ${worst.overage(kind)}.`; - return { level: 'warning', message, action: 'plans', actionLabel: 'Upgrade plan' }; + ? `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 Pay As You Go ${amount}, so ${worst.overage(kind)}.`, + message: `This app is above the ${worst.label} included in ${plan} ${amount}${plan in EXTRA_REQUESTS_PRICE ? `, so ${worst.overage(plan)}` : ''}.`, action: 'plans', - actionLabel: 'Upgrade to Dedicated', + actionLabel: upgradeLabel, }; } return null; @@ -146,7 +170,7 @@ export const getUsageBadge = planData => { if (kind === 'free' && worst.percent > WARNING_PERCENT) { return { percent: Math.min(100, percent), level: worst.percent > DANGER_PERCENT ? 'danger' : 'warning' }; } - if (kind === 'mvp' && worst.percent > WARNING_PERCENT) { + if ((kind === 'starter' || kind === 'mvp') && worst.percent > WARNING_PERCENT) { return { percent, level: 'warning' }; } if (kind === 'payg' && worst.percent >= 100) { diff --git a/src/lib/tests/PlanUsageBadge.test.js b/src/lib/tests/PlanUsageBadge.test.js index 182509bb80..4b18c69c42 100644 --- a/src/lib/tests/PlanUsageBadge.test.js +++ b/src/lib/tests/PlanUsageBadge.test.js @@ -67,6 +67,15 @@ describe('PlanUsageBadge on paid plans', () => { 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); }); diff --git a/src/lib/tests/usageAlert.test.js b/src/lib/tests/usageAlert.test.js index 8e8b5b7f89..17e68f0459 100644 --- a/src/lib/tests/usageAlert.test.js +++ b/src/lib/tests/usageAlert.test.js @@ -87,7 +87,7 @@ describe('getUsageAlert', () => { '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 plan'); + expect(alert.actionLabel).toBe('Upgrade to Pay As You Go'); }); it('past 100% it keeps running and pays the overage, still yellow', () => { @@ -126,8 +126,52 @@ describe('getUsageAlert', () => { }); }); - it('Dedicated gets no usage warnings', () => { - expect(getUsageAlert(plan({ planName: 'Dedicated Plan', dataStorageUsed: '7.9 GB', dataStorageLimit: '8 GB' }))).toBe(null); + 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 offers Dedicated', () => { + expect(getUsageAlert(legacy('Advanced Plan', { apiCallUsed: '49 K' }))).toBe(null); + const alert = getUsageAlert(legacy('Advanced Plan', { apiCallUsed: '55 K' })); + expect(alert.level).toBe('info'); + expect(alert.message).toBe('This app is above the API requests included in Advanced (55 K of 50 K).'); + expect(alert.actionLabel).toBe('Upgrade to Dedicated'); + }); + + 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', () => { From 4a637846769cb3df800eabcdd779003978e07b3a Mon Sep 17 00:00:00 2001 From: charles-ramos Date: Tue, 6 Oct 2026 15:17:18 -0300 Subject: [PATCH 17/17] Advanced over its limit: same overage message as Pay As You Go Advanced has the same limits and overage prices as Pay As You Go, so it now names the price of the extra usage instead of only the amount. Co-Authored-By: Claude Opus 5.5 --- src/dashboard/Data/AppOverview/usageAlert.js | 5 +++-- src/lib/tests/usageAlert.test.js | 12 ++++++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/dashboard/Data/AppOverview/usageAlert.js b/src/dashboard/Data/AppOverview/usageAlert.js index 8ba5a96271..751b0e468f 100644 --- a/src/dashboard/Data/AppOverview/usageAlert.js +++ b/src/dashboard/Data/AppOverview/usageAlert.js @@ -5,8 +5,9 @@ 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: legacy plans get the warning without a price. -const EXTRA_REQUESTS_PRICE = { MVP: '$5', 'Pay As You Go': '$2' }; +// 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' }; diff --git a/src/lib/tests/usageAlert.test.js b/src/lib/tests/usageAlert.test.js index 17e68f0459..4c09cbfd6c 100644 --- a/src/lib/tests/usageAlert.test.js +++ b/src/lib/tests/usageAlert.test.js @@ -154,12 +154,16 @@ describe('getUsageAlert', () => { }); }); - it('Advanced follows Pay As You Go: quiet until over its limit, then offers Dedicated', () => { - expect(getUsageAlert(legacy('Advanced Plan', { apiCallUsed: '49 K' }))).toBe(null); - const alert = getUsageAlert(legacy('Advanced Plan', { apiCallUsed: '55 K' })); + 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 (55 K of 50 K).'); + 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', () => {