In this section, you can enable a custom Server URL that can be used for real-time database.
diff --git a/src/dashboard/Settings/Collaborators.react.js b/src/dashboard/Settings/Collaborators.react.js
index 8336df5b51..9862ebff48 100644
--- a/src/dashboard/Settings/Collaborators.react.js
+++ b/src/dashboard/Settings/Collaborators.react.js
@@ -21,6 +21,8 @@ import React from 'react';
import TextInput from 'components/TextInput/TextInput.react';
import validateEmailFormat from 'lib/validateEmailFormat';
import { CurrentApp } from 'context/currentApp';
+import { UpgradeGate, UpgradeGateView, logGateClicked } from 'lib/upgradeEvents';
+import { UpgradeGateButton } from 'components/UpgradeCheckout/UpgradeCheckout.react';
import styles from 'dashboard/Settings/GeneralSettings.scss';
import buttonStyles from 'components/Button/Button.scss';
@@ -386,19 +388,17 @@ export default class Collaborators extends React.Component {
input={
{maxCollaborators === 0 || maxCollaborators === null || maxCollaborators === false ? (
-
- Upgrade Plan
-
+
+
+
) : maxCollaborators !== true && maxCollaborators !== null && collaboratorUsage >= maxCollaborators ? (
logGateClicked(UpgradeGate.COLLABORATORS, this.context.applicationId)}
>
+
Add More Spots
) : (
@@ -527,7 +527,8 @@ export default class Collaborators extends React.Component {
{' '}
Need more?{' '}
-
+ logGateClicked(UpgradeGate.COLLABORATORS, this.context.applicationId)}>
+
Add More Spots
diff --git a/src/lib/paddleCheckout.js b/src/lib/paddleCheckout.js
new file mode 100644
index 0000000000..3a72df8fee
--- /dev/null
+++ b/src/lib/paddleCheckout.js
@@ -0,0 +1,89 @@
+/* global b4aSettings */
+import { initializePaddle } from '@paddle/paddle-js';
+
+const SANDBOX_PRICE_ID = 'pri_01jjykwj65y5de1vcv5xaryw8g';
+
+export const Cycle = {
+ MONTHLY: 'monthly',
+ YEARLY: 'yearly',
+};
+
+export const paddlePriceId = (plan, cycle) => {
+ if (process.env.SENTRY_ENV !== 'production') {
+ return SANDBOX_PRICE_ID;
+ }
+ return cycle === Cycle.YEARLY ? plan.annuallyProductId : plan.monthlyProductId;
+};
+
+export const paddlePlanId = (plan, cycle) =>
+ cycle === Cycle.YEARLY ? plan.annuallyPlanId : plan.monthlyPlanId;
+
+// Paddle.js keeps a single global instance; a later call replaces the event callback,
+// so whoever opens a checkout must initialize right before opening it.
+export const initPaddle = (eventCallback) =>
+ initializePaddle({
+ token: b4aSettings.PADDLE_TOKEN || 'test_0270ab179b4f4abd7aa228c7014',
+ environment: process.env.SENTRY_ENV === 'production' ? 'production' : 'sandbox',
+ pwCustomer: {},
+ eventCallback,
+ });
+
+// Same side effects as the plan-usage checkout: link the subscription to the app,
+// then report the purchase to Amplitude with the funnel context.
+export const recordSubscription = async (data, funnelProps = {}) => {
+ window.dataLayer = window.dataLayer || [];
+ window.dataLayer.push({
+ event: 'paddle_purchase',
+ value: Number(data.data.totals.total),
+ currency: data.data.currency_code,
+ transaction_id: data.data.transaction_id,
+ });
+
+ const customData = data.data.custom_data || {};
+ const paymentData = {
+ appId: customData.app_id || customData.appId,
+ 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: customData.plan_id || customData.planId,
+ 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) {
+ console.log('save-subscription error:', e);
+ }
+
+ try {
+ await fetch('https://api.amplitude.com/2/httpapi', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ 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,
+ ...funnelProps,
+ },
+ },
+ ],
+ }),
+ });
+ } catch (error) {
+ console.log('Amplitude error (checkout.completed):', error);
+ }
+};
diff --git a/src/lib/serverInfo.js b/src/lib/serverInfo.js
index e0b6b7721d..367b8b99bb 100644
--- a/src/lib/serverInfo.js
+++ b/src/lib/serverInfo.js
@@ -23,6 +23,7 @@ export const ALWAYS_ALLOWED_ROUTES = [
'security',
'domain-settings',
'Domain Settings',
+ 'Domains',
'Advanced Options',
'advanced-options',
'environment-variable',
@@ -41,6 +42,7 @@ export const ALWAYS_ALLOWED_ROUTES = [
'push/ios-settings',
'server-url-live-query',
'Server URL & Live Query',
+ 'Real-time (Live Query)',
];
export const canAccess = (serverInfo, route) => {
diff --git a/src/lib/tests/UpgradeCheckout.test.js b/src/lib/tests/UpgradeCheckout.test.js
new file mode 100644
index 0000000000..d561771900
--- /dev/null
+++ b/src/lib/tests/UpgradeCheckout.test.js
@@ -0,0 +1,234 @@
+// The test preprocessor does not hoist jest.mock, so mocked modules are required after it.
+jest.mock('../amplitudeEvents', () => ({ amplitudeLogEvent: jest.fn() }));
+jest.mock('../AccountManager', () => ({ currentUser: () => ({ email: 'owner@example.com' }) }));
+jest.mock('../paddleCheckout', () => {
+ const actual = jest.requireActual('../paddleCheckout');
+ return { ...actual, initPaddle: jest.fn(), recordSubscription: jest.fn(() => Promise.resolve()) };
+});
+jest.mock('../../components/Icon/Icon.react', () => ({ __esModule: true, default: () => null }));
+jest.mock('../../components/Popover/Popover.react', () => {
+ const React = require('react');
+ return { __esModule: true, default: ({ children }) =>
{children}
};
+});
+jest.mock('context/currentApp', () => ({ CurrentApp: require('react').createContext(null) }), { virtual: true });
+jest.mock(
+ 'dashboard/AppPlan/AppPlan.react',
+ () => ({
+ 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' }],
+ },
+ {
+ name: 'Pay As You Go', pricePerMonth: '100', pricePerYear: '80', savePercent: '20%',
+ monthlyPlanId: 'payg-m', annuallyPlanId: 'payg-y', monthlyProductId: 'pri_payg_m', annuallyProductId: 'pri_payg_y',
+ details: [],
+ },
+ {
+ name: 'Dedicated', pricePerMonth: '500', pricePerYear: '400', savePercent: '20%',
+ monthlyPlanId: 'ded-m', annuallyPlanId: 'ded-y', monthlyProductId: 'pri_ded_m', annuallyProductId: 'pri_ded_y',
+ details: [],
+ },
+ ],
+ }),
+ { virtual: true }
+);
+
+const React = require('react');
+const renderer = require('react-test-renderer');
+const { CurrentApp } = require('context/currentApp');
+const { amplitudeLogEvent } = require('../amplitudeEvents');
+const { initPaddle, recordSubscription } = require('../paddleCheckout');
+const { UpgradeCheckoutModal, UpgradeGateButton } = require('../../components/UpgradeCheckout/UpgradeCheckout.react');
+
+const app = { applicationId: 'app-1', slug: 'my-app', custom: { isOwner: true } };
+
+const fakePaddle = () => ({ Checkout: { open: jest.fn(), updateCheckout: jest.fn(), close: jest.fn() } });
+
+const loggedEvents = name => amplitudeLogEvent.mock.calls.filter(([event]) => event === name).map(([, props]) => props);
+
+const textOf = tree => JSON.stringify(tree.toJSON());
+
+// Renders inside the app context and lets the Paddle/owner-email promises settle.
+const mount = async element => {
+ let tree;
+ await renderer.act(async () => {
+ tree = renderer.create(
{element}, {
+ createNodeMock: () => ({}),
+ });
+ });
+ return tree;
+};
+
+describe('UpgradeCheckoutModal', () => {
+ let paddle;
+ let paddleCallback;
+ const prevEnv = process.env.SENTRY_ENV;
+
+ beforeEach(() => {
+ process.env.SENTRY_ENV = 'production';
+ paddle = fakePaddle();
+ amplitudeLogEvent.mockClear();
+ recordSubscription.mockClear();
+ initPaddle.mockReset();
+ initPaddle.mockImplementation(callback => {
+ paddleCallback = callback;
+ return Promise.resolve(paddle);
+ });
+ });
+
+ afterAll(() => {
+ process.env.SENTRY_ENV = prevEnv;
+ });
+
+ it('opens the MVP yearly checkout right away for a custom domain paywall', async () => {
+ const tree = await mount(
{}} />);
+
+ expect(paddle.Checkout.open).toHaveBeenCalledTimes(1);
+ const options = paddle.Checkout.open.mock.calls[0][0];
+ expect(options.items).toEqual([{ priceId: 'pri_mvp_y', quantity: 1 }]);
+ expect(options.customData).toEqual({ appId: 'app-1', planId: 'mvp-y' });
+ expect(options.customer).toEqual({ email: 'owner@example.com' });
+ 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' },
+ ]);
+ const text = textOf(tree);
+ expect(text).toContain('Put your API and pages on your own domain');
+ expect(text).toContain('Upgrade to ');
+ expect(text).toContain('Web hosting & custom domain');
+ });
+
+ it('starts on yearly and switches the open checkout to monthly without reopening it', async () => {
+ 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');
+
+ // Yearly is listed first, monthly second.
+ renderer.act(() => radios[1].props.onChange());
+
+ expect(paddle.Checkout.open).toHaveBeenCalledTimes(1);
+ expect(paddle.Checkout.updateCheckout).toHaveBeenCalledWith({
+ items: [{ priceId: 'pri_mvp_m', quantity: 1 }],
+ 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' },
+ ]);
+ expect(textOf(tree)).toContain('Billed monthly');
+ });
+
+ it('logs the abandonment and closes when the user leaves without paying', async () => {
+ const onClose = jest.fn();
+ const tree = await mount();
+
+ renderer.act(() => tree.root.findByProps({ 'aria-label': 'Close' }).props.onClick());
+
+ expect(loggedEvents('baas_checkout_closed')).toEqual([
+ { app_id: 'app-1', plan: 'MVP', cycle: 'yearly', source: 'gate', gate: 'email_templates' },
+ ]);
+ expect(paddle.Checkout.close).toHaveBeenCalled();
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it('records the purchase with the gate and does not count it as abandoned', async () => {
+ const tree = await mount( {}} />);
+ const completed = { name: 'checkout.completed', data: {} };
+
+ await renderer.act(async () => {
+ await paddleCallback(completed);
+ });
+
+ expect(recordSubscription).toHaveBeenCalledWith(completed, { source: 'gate', gate: 'collaborators' });
+ expect(textOf(tree)).toContain('You are on ');
+
+ // Tests run in the node environment, so stub the window the component reloads.
+ const reload = jest.fn();
+ global.window = { location: { reload } };
+ renderer.act(() => tree.root.findByProps({ 'aria-label': 'Close' }).props.onClick());
+ delete global.window;
+
+ expect(reload).toHaveBeenCalled();
+ expect(loggedEvents('baas_checkout_closed')).toEqual([]);
+ });
+
+ it('follows the light or dark preference, including the Paddle frame', async () => {
+ 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( {}} />);
+ delete global.window;
+
+ expect(paddle.Checkout.open.mock.calls[0][0].settings.theme).toBe('dark');
+ expect(tree.root.findAll(node => typeof node.props.className === 'string' && node.props.className.includes('dark')).length).toBeGreaterThan(0);
+ });
+
+ 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');
+ });
+
+ it('sells HTTPS on Pay As You Go and tells the buyer support turns it on', async () => {
+ const tree = await mount( {}} />);
+ expect(paddle.Checkout.open.mock.calls[0][0].items[0].priceId).toBe('pri_payg_y');
+ expect(textOf(tree)).not.toContain('open a support ticket');
+
+ await renderer.act(async () => {
+ await paddleCallback({ name: 'checkout.completed', data: {} });
+ });
+ const text = textOf(tree);
+ expect(text).toContain('open a support ticket');
+ expect(tree.root.findByProps({ href: 'https://help.back4app.com/hc/en-us/requests/new' })).toBeTruthy();
+ });
+
+ it('sells MVP as a whole from the generic Overview upgrade buttons', async () => {
+ for (const gate of ['overview_plan_card', 'overview_plan_badge']) {
+ paddle.Checkout.open.mockClear();
+ const tree = await mount( {}} />);
+ expect(paddle.Checkout.open.mock.calls[0][0].items[0].priceId).toBe('pri_mvp_y');
+ expect(textOf(tree)).toContain('Take this app to production');
+ }
+ });
+
+ 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');
+
+ paddle.Checkout.open.mockClear();
+ await mount( {}} />);
+ expect(paddle.Checkout.open.mock.calls[0][0].items[0].priceId).toBe('pri_ded_y');
+ });
+});
+
+describe('UpgradeGateButton', () => {
+ beforeEach(() => {
+ amplitudeLogEvent.mockClear();
+ initPaddle.mockReset();
+ initPaddle.mockImplementation(() => Promise.resolve(fakePaddle()));
+ });
+
+ it('logs the paywall view, then the click, and opens the checkout in place', async () => {
+ const tree = await mount(
+ Upgrade database} />
+ );
+ expect(loggedEvents('baas_upgrade_gate_viewed')).toEqual([{ gate: 'mongodb_8', app_id: 'app-1' }]);
+ expect(initPaddle).not.toHaveBeenCalled();
+
+ const preventDefault = jest.fn();
+ await renderer.act(async () => {
+ tree.root.findByProps({ id: 'trigger' }).props.onClick({ preventDefault });
+ });
+
+ expect(preventDefault).toHaveBeenCalled();
+ expect(loggedEvents('baas_upgrade_gate_clicked')).toEqual([{ gate: 'mongodb_8', app_id: 'app-1' }]);
+ expect(initPaddle).toHaveBeenCalledTimes(1);
+ expect(textOf(tree)).toContain('Get the latest MongoDB');
+ expect(textOf(tree)).toContain('Faster queries and the newest MongoDB features.');
+ });
+});
diff --git a/src/lib/tests/upgradeEvents.test.js b/src/lib/tests/upgradeEvents.test.js
new file mode 100644
index 0000000000..5f6992baa5
--- /dev/null
+++ b/src/lib/tests/upgradeEvents.test.js
@@ -0,0 +1,51 @@
+jest.mock('../amplitudeEvents', () => ({ amplitudeLogEvent: jest.fn() }));
+
+// The test preprocessor does not hoist jest.mock, so mocked modules are required after it.
+const React = require('react');
+const renderer = require('react-test-renderer');
+const { amplitudeLogEvent } = require('../amplitudeEvents');
+const {
+ UpgradeEvent,
+ UpgradeGate,
+ UpgradeGateView,
+ getGateFromSearch,
+ logGateClicked,
+ planUsagePath,
+} = require('../upgradeEvents');
+
+describe('upgradeEvents', () => {
+ beforeEach(() => amplitudeLogEvent.mockClear());
+
+ it('builds the plan-usage path with the gate', () => {
+ expect(planUsagePath('my-app', UpgradeGate.CUSTOM_DOMAIN)).toBe('/apps/my-app/plan-usage?gate=custom_domain');
+ });
+
+ it('reads the gate back from the query string', () => {
+ expect(getGateFromSearch('?gate=custom_domain')).toBe('custom_domain');
+ expect(getGateFromSearch('?foo=bar')).toBe(null);
+ expect(getGateFromSearch('')).toBe(null);
+ expect(getGateFromSearch(undefined)).toBe(null);
+ });
+
+ it('logs a gate click with the app id', () => {
+ logGateClicked(UpgradeGate.JOBS, 'app-1');
+ expect(amplitudeLogEvent).toHaveBeenCalledWith('baas_upgrade_gate_clicked', { gate: 'jobs', app_id: 'app-1' });
+ });
+
+ it('logs a gate view once when rendered, not on every re-render', () => {
+ let tree;
+ renderer.act(() => {
+ tree = renderer.create();
+ });
+ renderer.act(() => {
+ tree.update();
+ });
+ expect(tree.toJSON()).toBe(null);
+ expect(amplitudeLogEvent).toHaveBeenCalledTimes(1);
+ expect(amplitudeLogEvent).toHaveBeenCalledWith(UpgradeEvent.GATE_VIEWED, { gate: 'mongodb_8', app_id: 'app-1' });
+ });
+
+ it('keeps baas_ event names apart from the Agent upgrade_* events', () => {
+ Object.values(UpgradeEvent).forEach(name => expect(name.startsWith('baas_')).toBe(true));
+ });
+});
diff --git a/src/lib/upgradeEvents.js b/src/lib/upgradeEvents.js
new file mode 100644
index 0000000000..a46f3d5057
--- /dev/null
+++ b/src/lib/upgradeEvents.js
@@ -0,0 +1,54 @@
+import { useEffect } from 'react';
+import { amplitudeLogEvent } from './amplitudeEvents';
+
+// Free -> paid funnel for the Backend product:
+// gate viewed -> gate clicked -> checkout opened -> (closed | At Checkout - Subscription Successful).
+// Prefixed with baas_ so the Agent's upgrade_* charts keep counting only the Agent.
+export const UpgradeEvent = {
+ GATE_VIEWED: 'baas_upgrade_gate_viewed',
+ GATE_CLICKED: 'baas_upgrade_gate_clicked',
+ CHECKOUT_OPENED: 'baas_checkout_opened',
+ CHECKOUT_CLOSED: 'baas_checkout_closed',
+ CHECKOUT_CYCLE_CHANGED: 'baas_checkout_cycle_changed',
+};
+
+// The paid feature that made the user hit the paywall. Also sent as ?gate= to plan-usage
+// so the checkout events there know where the user came from.
+export const UpgradeGate = {
+ WEB_HOSTING: 'web_hosting',
+ CUSTOM_DOMAIN: 'custom_domain',
+ EMAIL_TEMPLATES: 'email_templates',
+ PARSE_OPTIONS: 'parse_options',
+ JOBS: 'jobs',
+ COLLABORATORS: 'collaborators',
+ MONGODB_8: 'mongodb_8',
+ DB_PROFILER: 'db_profiler',
+ OVERVIEW_PLAN_CARD: 'overview_plan_card',
+ OVERVIEW_PLAN_BADGE: 'overview_plan_badge',
+ OVERVIEW_WEB_HOSTING: 'overview_web_hosting',
+ HTTPS: 'https',
+ COMPLIANCE_HIPAA: 'compliance_hipaa',
+ COMPLIANCE_SOC2: 'compliance_soc2',
+ COMPLIANCE_ISO27001: 'compliance_iso27001',
+};
+
+export const planUsagePath = (slug, gate) => `/apps/${slug}/plan-usage?gate=${gate}`;
+
+export const getGateFromSearch = (search) => {
+ try {
+ return new URLSearchParams(search || '').get('gate') || null;
+ } catch (e) {
+ return null;
+ }
+};
+
+export const logGateClicked = (gate, appId) =>
+ amplitudeLogEvent(UpgradeEvent.GATE_CLICKED, { gate, app_id: appId });
+
+// Renders nothing; logs baas_upgrade_gate_viewed once when the paywall is shown.
+export const UpgradeGateView = ({ gate, appId }) => {
+ useEffect(() => {
+ amplitudeLogEvent(UpgradeEvent.GATE_VIEWED, { gate, app_id: appId });
+ }, [gate, appId]);
+ return null;
+};