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