diff --git a/src/components/UpgradeCheckout/UpgradeCheckout.react.js b/src/components/UpgradeCheckout/UpgradeCheckout.react.js new file mode 100644 index 0000000000..51b48c4865 --- /dev/null +++ b/src/components/UpgradeCheckout/UpgradeCheckout.react.js @@ -0,0 +1,249 @@ +import React, { useContext, useEffect, useRef, useState } from 'react'; +import Popover from 'components/Popover/Popover.react'; +import Position from 'lib/Position'; +import Button from 'components/Button/Button.react'; +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 { 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 = () => + typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + +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.', +}; + +// 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.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' }, + // 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, + [UpgradeGate.MONGODB_8]: { + plan: 'MVP', + headline: 'Get the latest MongoDB', + subline: 'Faster queries and the newest MongoDB features.', + }, + // 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.', + }, + [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.' }, +}; + +const detailText = detail => [detail.number, detail.text].filter(Boolean).join(' '); + +const priceSummary = (plan, cycle) => { + if (cycle === Cycle.YEARLY) { + const yearly = Number(plan.pricePerYear) * 12; + return { perMonth: plan.pricePerYear, note: `Billed $${yearly}/year · save ${plan.savePercent}`, today: yearly }; + } + return { perMonth: plan.pricePerMonth, note: 'Billed monthly · cancel anytime', today: Number(plan.pricePerMonth) }; +}; + +const getOwnerEmail = async context => { + if (context.custom && context.custom.isOwner) { + return AccountManager.currentUser().email; + } + const { ownerEmail } = await context.getAppOwnerEmail(); + return ownerEmail; +}; + +// Opens the Paddle checkout for the plan that unlocks `gate`, skipping the plan comparison page. +export const UpgradeCheckoutModal = ({ gate, onClose }) => { + const context = useContext(CurrentApp); + const offer = GATE_OFFERS[gate]; + const plan = prices.find(p => p.name === offer.plan); + + const [dark] = useState(prefersDark); + // Yearly first, like the pricing page; monthly stays one click away. + const [cycle, setCycle] = useState(Cycle.YEARLY); + const [paddle, setPaddle] = useState(null); + const [ownerEmail, setOwnerEmail] = useState(null); + const [frame, setFrame] = useState(null); + const [completed, setCompleted] = useState(false); + const [error, setError] = useState(null); + const funnel = useRef({ app_id: context.applicationId, plan: plan.name, cycle, source: 'gate', gate }); + const opened = useRef(false); + + useEffect(() => { + let active = true; + initPaddle(async data => { + if (data.name === 'checkout.completed') { + setCompleted(true); + await recordSubscription(data, { source: 'gate', gate }); + } + }).then(instance => active && setPaddle(instance || null)).catch(e => active && setError(e)); + getOwnerEmail(context).then(email => active && setOwnerEmail(email)).catch(e => active && setError(e)); + return () => { + active = false; + }; + }, []); + + useEffect(() => { + if (!paddle || !ownerEmail || !frame || opened.current) { + return; + } + opened.current = true; + amplitudeLogEvent(UpgradeEvent.CHECKOUT_OPENED, funnel.current); + paddle.Checkout.open({ + items: [{ priceId: paddlePriceId(plan, cycle), quantity: 1 }], + settings: { + displayMode: 'inline', + theme: dark ? 'dark' : 'light', + locale: 'en', + variant: 'one-page', + frameTarget: CHECKOUT_FRAME_CLASS, + frameInitialHeight: '450', + frameStyle: 'width: 100%; min-width: 312px; background-color: transparent; border: none;', + }, + customData: { appId: context.applicationId, planId: paddlePlanId(plan, cycle) }, + allowLogout: false, + customer: { email: ownerEmail }, + }); + }, [paddle, ownerEmail, frame]); + + const changeCycle = next => { + if (next === cycle || completed) { + return; + } + setCycle(next); + funnel.current = { ...funnel.current, cycle: next }; + amplitudeLogEvent(UpgradeEvent.CHECKOUT_CYCLE_CHANGED, funnel.current); + if (opened.current) { + paddle.Checkout.updateCheckout({ + items: [{ priceId: paddlePriceId(plan, next), quantity: 1 }], + customData: { appId: context.applicationId, planId: paddlePlanId(plan, next) }, + }); + } + }; + + const close = () => { + if (completed) { + // Reload so the paywall that brought the user here reflects the new plan. + window.location.reload(); + return; + } + amplitudeLogEvent(UpgradeEvent.CHECKOUT_CLOSED, funnel.current); + if (paddle && opened.current) { + paddle.Checkout.close(); + } + onClose(); + }; + + const price = priceSummary(plan, cycle); + + return ( + +
+ +
+
Upgrade to {plan.name}
+
{offer.headline}
+ {offer.subline ?
{offer.subline}
: null} +
+ ${price.perMonth} + /month +
+
{price.note}
+ +
+ + +
+ +
+ {plan.details.map((detail, idx) => ( +
+ + {detailText(detail)} +
+ ))} +
+ +
+ Total today + ${price.today} + tax +
+ + {completed ? ( +
+
+ You are on {plan.name} now. + {offer.nextStep ? ( +
+ {offer.nextStep}{' '} + Open a ticket +
+ ) : null} +
+
+ ) : ( + + Compare all plans + + )} +
+
+ {error ? ( +
We could not load the checkout. Please refresh the page and try again.
+ ) : null} +
+
+
+
+ ); +}; + +// Paywall entry point: logs the gate view, and on click opens the direct checkout. +// `renderTrigger(open)` lets anchors and custom buttons keep their look. +export const UpgradeGateButton = ({ gate, renderTrigger, value = 'Upgrade Plan' }) => { + const context = useContext(CurrentApp); + const [open, setOpen] = useState(false); + const openCheckout = event => { + if (event && event.preventDefault) { + event.preventDefault(); + } + logGateClicked(gate, context.applicationId); + setOpen(true); + }; + + return ( + <> + + {renderTrigger ? renderTrigger(openCheckout) : } + {showUpgrade && ( + ( + + )} + /> + )} {showSignBAA && amplitudeLogEvent(`On Click - At Sign BAA for ${type} compliance`)} target="_blank" rel="noopener noreferrer">} diff --git a/src/dashboard/Data/Jobs/Jobs.react.js b/src/dashboard/Data/Jobs/Jobs.react.js index 2cb8a76df3..f2cc6a0522 100644 --- a/src/dashboard/Data/Jobs/Jobs.react.js +++ b/src/dashboard/Data/Jobs/Jobs.react.js @@ -18,6 +18,7 @@ import FormNote from 'components/FormNote/FormNote.react'; import Popover from 'components/Popover/Popover.react'; import Position from 'lib/Position'; import React from 'react'; +import { UpgradeGate, UpgradeGateView, logGateClicked } from 'lib/upgradeEvents'; import ReleaseInfo from 'components/ReleaseInfo/ReleaseInfo'; import RunNowButton from 'dashboard/Data/Jobs/RunNowButton.react'; import SidebarAction from 'components/Sidebar/SidebarAction'; @@ -434,9 +435,11 @@ class Jobs extends TableView { buttonsInCenter={false} onCancel={() => this.setState({ jobLimitReached: false })} onConfirm={() => { - this.props.navigate(generatePath(this.context, 'plan-usage')); + logGateClicked(UpgradeGate.JOBS, this.context.applicationId); + this.props.navigate(generatePath(this.context, `plan-usage?gate=${UpgradeGate.JOBS}`)); }} > +
Upgrade your plan to schedule additional background jobs.
diff --git a/src/dashboard/DatabaseProfiler/DatabaseProfiler.react.js b/src/dashboard/DatabaseProfiler/DatabaseProfiler.react.js index 824f1eb5ca..b6e76b90dd 100644 --- a/src/dashboard/DatabaseProfiler/DatabaseProfiler.react.js +++ b/src/dashboard/DatabaseProfiler/DatabaseProfiler.react.js @@ -20,6 +20,8 @@ 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'; @withRouter @@ -113,6 +115,7 @@ class DatabaseProfile extends DashboardView { 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, @@ -147,6 +150,8 @@ class DatabaseProfile extends DashboardView { planName: paymentData.planName, planType: data.data.items[0].billing_cycle.interval, subscriptionTotal: data.data.totals.total, + source: 'gate', + gate: UpgradeGate.DB_PROFILER, } } ] @@ -233,6 +238,16 @@ class DatabaseProfile extends DashboardView { // 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; @@ -258,7 +273,7 @@ class DatabaseProfile extends DashboardView { }; handleComparePlansClick = (applicationId) => { - window.location.href = `${b4aSettings.BACKEND_DASHBOARD_PATH}/apps/${applicationId}/plan-usage`; + window.location.href = `${b4aSettings.BACKEND_DASHBOARD_PATH}/apps/${applicationId}/plan-usage?gate=${UpgradeGate.DB_PROFILER}`; }; renderError() { @@ -278,6 +293,7 @@ class DatabaseProfile extends DashboardView { title="Upgrade Required" description="Query Performance Monitor is available exclusively on Dedicated plans." /> + {/* Dedicated plan highlight card */}
} - onCancel={() => this.setState({ openCheckout: false })} + onCancel={() => { + if (!this.checkoutCompleted) { + amplitudeLogEvent(UpgradeEvent.CHECKOUT_CLOSED, this.checkoutContext); + } + this.setState({ openCheckout: false }); + }} >
diff --git a/src/dashboard/DomainSettings/DomainSettings.react.js b/src/dashboard/DomainSettings/DomainSettings.react.js index 2a199bb810..a79a2079dd 100644 --- a/src/dashboard/DomainSettings/DomainSettings.react.js +++ b/src/dashboard/DomainSettings/DomainSettings.react.js @@ -22,16 +22,17 @@ 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 { UpgradeGateButton } from 'components/UpgradeCheckout/UpgradeCheckout.react'; import B4aNotification from 'dashboard/Data/Browser/B4aNotification.react'; import browserStyles from 'dashboard/Data/Browser/Browser.scss'; -import { Link } from 'react-router-dom'; @withRouter class DomainSettings extends DashboardView { constructor() { super(); this.section = 'App Settings'; - this.subsection = 'Domain Settings'; + this.subsection = 'Domains'; this.state = { isLoading: true, domainSettings: {}, @@ -190,7 +191,7 @@ class DomainSettings extends DashboardView { renderToolbar() { return ( - + @@ -405,14 +406,9 @@ class DomainSettings extends DashboardView { !this.state.canChangeSubdomain && !this.state.isActivated && this.state.currentSubdomain.trim().length === 0 && this.state.customDomainArray.length === 0) { content =
} + label={