diff --git a/src/components/UpgradeCheckout/BackupUpsell.react.js b/src/components/UpgradeCheckout/BackupUpsell.react.js index 86d1be64c1..c5a9722e31 100644 --- a/src/components/UpgradeCheckout/BackupUpsell.react.js +++ b/src/components/UpgradeCheckout/BackupUpsell.react.js @@ -38,7 +38,7 @@ const BackupUpsell = ({ gate }) => { } input={
- +
} /> diff --git a/src/components/UpgradeCheckout/UpgradeCheckout.react.js b/src/components/UpgradeCheckout/UpgradeCheckout.react.js index 73ba225e5c..fc5217914a 100644 --- a/src/components/UpgradeCheckout/UpgradeCheckout.react.js +++ b/src/components/UpgradeCheckout/UpgradeCheckout.react.js @@ -6,7 +6,7 @@ import Icon from 'components/Icon/Icon.react'; import AccountManager from 'lib/AccountManager'; import { CurrentApp } from 'context/currentApp'; import { amplitudeLogEvent } from 'lib/amplitudeEvents'; -import { SUPPORT_TICKET_URL, UpgradeEvent, UpgradeGate, UpgradeGateView, logGateClicked, planUsagePath, regionChangeFormUrl } from 'lib/upgradeEvents'; +import { SUPPORT_TICKET_URL, UpgradeEvent, UpgradeGate, UpgradeGateView, logGateClicked, planUsagePath, regionChangeFormUrl, trackPaddleEvent } from 'lib/upgradeEvents'; import { Cycle, initPaddle, paddlePlanId, paddlePriceId, recordSubscription } from 'lib/paddleCheckout'; import { prices } from 'dashboard/AppPlan/prices'; import { getCachedPlanData } from 'lib/planDataCache'; @@ -137,14 +137,6 @@ const planIndex = name => prices.findIndex(p => p.name === name); 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; @@ -160,8 +152,8 @@ export const UpgradeCheckoutModal = ({ gate, plan: planName, onClose }) => { 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); + // Monthly only: a paywall sells the plan at its monthly price. Yearly stays on Plan Usage. + const cycle = Cycle.MONTHLY; const [paddle, setPaddle] = useState(null); const [ownerEmail, setOwnerEmail] = useState(null); const [frame, setFrame] = useState(null); @@ -169,10 +161,13 @@ export const UpgradeCheckoutModal = ({ gate, plan: planName, onClose }) => { const [error, setError] = useState(null); const funnel = useRef({ app_id: context.applicationId, plan: plan.name, cycle, source: 'gate', gate }); const opened = useRef(false); + // The Paddle events already logged for this checkout (the modal opens one). + const paddleSeen = useRef(new Set()); useEffect(() => { let active = true; initPaddle(async data => { + trackPaddleEvent(data, funnel.current, paddleSeen.current); if (data.name === 'checkout.completed') { setCompleted(true); await recordSubscription(data, { source: 'gate', gate }); @@ -207,21 +202,6 @@ export const UpgradeCheckoutModal = ({ gate, plan: planName, onClose }) => { }); }, [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. @@ -235,8 +215,6 @@ export const UpgradeCheckoutModal = ({ gate, plan: planName, onClose }) => { onClose(); }; - const price = priceSummary(plan, cycle); - return (
@@ -248,21 +226,10 @@ export const UpgradeCheckoutModal = ({ gate, plan: planName, onClose }) => {
{offer.headline}
{offer.subline ?
{offer.subline}
: null}
- ${price.perMonth} + ${plan.pricePerMonth} /month
-
{price.note}
- -
- - -
+
Billed monthly · cancel anytime
{plan.details.map((detail, idx) => ( @@ -275,7 +242,7 @@ export const UpgradeCheckoutModal = ({ gate, plan: planName, onClose }) => {
Total today - ${price.today} + tax + ${plan.pricePerMonth} + tax
{completed ? ( @@ -315,13 +282,16 @@ const goToPlanUsage = (slug, gate) => { window.location.assign(`${base}${planUsagePath(slug, gate)}`); }; -// Paywall entry point: logs the gate view and, on click, opens the direct checkout when the +// Paywall entry point: on click, logs the gate click and opens the direct checkout when the // plan it sells is above the app's plan (MVP on Free, Pay As You Go on MVP, ...). Paywalls // follow the backend permissions, so they also show on plans at or above the one they sell, // and on plans with no next plan up (Gold, custom, ...): those go to Plan Usage instead of // buying a plan below their own. `plan` picks the plan when the gate sells more than one. // `renderTrigger(open)` lets anchors and custom buttons keep their look. -export const UpgradeGateButton = ({ gate, plan, renderTrigger, value = 'Upgrade Plan' }) => { +// `logView` also logs the gate view when the paywall shows. Only for paywalls the user reaches by +// trying to use the feature (opening its page); buttons that are always on screen, like the +// Overview ones, count only the click. +export const UpgradeGateButton = ({ gate, plan, renderTrigger, value = 'Upgrade Plan', logView = false }) => { const context = useContext(CurrentApp); const [open, setOpen] = useState(false); const openCheckout = event => { @@ -340,7 +310,7 @@ export const UpgradeGateButton = ({ gate, plan, renderTrigger, value = 'Upgrade return ( <> - + {logView ? : null} {renderTrigger ? renderTrigger(openCheckout) :
diff --git a/src/dashboard/DomainSettings/DomainSettings.react.js b/src/dashboard/DomainSettings/DomainSettings.react.js index be2e9b1ab7..ce2f2e14a0 100644 --- a/src/dashboard/DomainSettings/DomainSettings.react.js +++ b/src/dashboard/DomainSettings/DomainSettings.react.js @@ -408,7 +408,7 @@ class DomainSettings extends DashboardView { } input={
- +
} theme={Field.Theme.BLUE} /> @@ -427,7 +427,7 @@ class DomainSettings extends DashboardView { disabled={!this.state.canChangeSubdomain && this.state.currentSubdomain.trim().length === 0} /> ) : ( - + )}
} @@ -558,7 +558,7 @@ class DomainSettings extends DashboardView { /> ) : ( - + )} } diff --git a/src/dashboard/Notification/EmailPasswordReset.react.js b/src/dashboard/Notification/EmailPasswordReset.react.js index 7023f55609..011646325f 100644 --- a/src/dashboard/Notification/EmailPasswordReset.react.js +++ b/src/dashboard/Notification/EmailPasswordReset.react.js @@ -279,7 +279,7 @@ class EmailPasswordReset extends DashboardView { } input={
- +
} theme={Field.Theme.BLUE} diff --git a/src/dashboard/Notification/EmailVerification.react.js b/src/dashboard/Notification/EmailVerification.react.js index a2d87d95a0..ce25f708a2 100644 --- a/src/dashboard/Notification/EmailVerification.react.js +++ b/src/dashboard/Notification/EmailVerification.react.js @@ -471,7 +471,7 @@ class EmailVerification extends DashboardView { } input={
- +
} theme={Field.Theme.BLUE} diff --git a/src/dashboard/Settings/Collaborators.react.js b/src/dashboard/Settings/Collaborators.react.js index 9862ebff48..839c6c639c 100644 --- a/src/dashboard/Settings/Collaborators.react.js +++ b/src/dashboard/Settings/Collaborators.react.js @@ -389,7 +389,7 @@ export default class Collaborators extends React.Component {
{maxCollaborators === 0 || maxCollaborators === null || maxCollaborators === false ? (
- +
) : maxCollaborators !== true && maxCollaborators !== null && collaboratorUsage >= maxCollaborators ? ( { process.env.SENTRY_ENV = prevEnv; }); - it('opens the MVP yearly checkout right away for a custom domain paywall', async () => { + it('opens the MVP monthly checkout right away for a web hosting 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.items).toEqual([{ priceId: 'pri_mvp_m', quantity: 1 }]); + expect(options.customData).toEqual({ appId: 'app-1', planId: 'mvp-m' }); 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: 'web_hosting' }, + { app_id: 'app-1', plan: 'MVP', cycle: 'monthly', source: 'gate', gate: 'web_hosting' }, ]); const text = textOf(tree); expect(text).toContain('Host your pages on a b4a.app subdomain'); @@ -111,24 +111,13 @@ describe('UpgradeCheckoutModal', () => { 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 () => { + it('sells the monthly price only, with no yearly option', 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()); + const text = textOf(tree); - 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: 'web_hosting' }, - ]); - expect(textOf(tree)).toContain('Billed monthly'); + expect(text).toContain('Billed monthly'); + expect(text).not.toMatch(/yearly|\/year|180/i); + expect(tree.root.findAll(node => node.type === 'input' && node.props.type === 'radio')).toHaveLength(0); }); it('logs the abandonment and closes when the user leaves without paying', async () => { @@ -138,7 +127,7 @@ describe('UpgradeCheckoutModal', () => { 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' }, + { app_id: 'app-1', plan: 'MVP', cycle: 'monthly', source: 'gate', gate: 'email_templates' }, ]); expect(paddle.Checkout.close).toHaveBeenCalled(); expect(onClose).toHaveBeenCalled(); @@ -165,6 +154,32 @@ describe('UpgradeCheckoutModal', () => { expect(loggedEvents('baas_checkout_closed')).toEqual([]); }); + it('logs what Paddle reports from inside the checkout, with the gate', async () => { + await mount( {}} />); + const funnel = { app_id: 'app-1', plan: 'MVP', cycle: 'monthly', source: 'gate', gate: 'web_hosting' }; + + await renderer.act(async () => { + await paddleCallback({ name: 'checkout.loaded', data: { id: 'che_1' } }); + await paddleCallback({ + name: 'checkout.warning', + data: { id: 'che_1' }, + error: { type: 'validation_error', code: 'postcode_required', detail: 'Postcode is required' }, + }); + }); + + expect(loggedEvents('baas_checkout_loaded')).toEqual([{ ...funnel, checkout_id: 'che_1' }]); + expect(loggedEvents('baas_checkout_event')).toEqual([ + { + ...funnel, + name: 'checkout.warning', + checkout_id: 'che_1', + error_type: 'validation_error', + error_code: 'postcode_required', + error_detail: 'Postcode is required', + }, + ]); + }); + 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'); @@ -180,13 +195,13 @@ 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(paddle.Checkout.open.mock.calls[0][0].items[0].priceId).toBe('pri_mvp_m'); 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 () => { const tree = await mount( {}} />); - expect(paddle.Checkout.open.mock.calls[0][0].items[0].priceId).toBe('pri_payg_y'); + expect(paddle.Checkout.open.mock.calls[0][0].items[0].priceId).toBe('pri_payg_m'); expect(textOf(tree)).not.toContain('open a support ticket'); await renderer.act(async () => { @@ -201,20 +216,20 @@ describe('UpgradeCheckoutModal', () => { 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(paddle.Checkout.open.mock.calls[0][0].items[0].priceId).toBe('pri_mvp_m'); expect(textOf(tree)).toContain('Take this app to production'); } }); 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(paddle.Checkout.open.mock.calls[0][0].items[0].priceId).toBe('pri_mvp_m'); expect(textOf(tree)).toContain('Schedule more background jobs'); }); - it('sells Dedicated on yearly from the database profiler', async () => { + it('sells Dedicated 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(paddle.Checkout.open.mock.calls[0][0].items[0].priceId).toBe('pri_ded_m'); expect(textOf(tree)).toContain('See which queries slow your app down'); }); @@ -230,34 +245,34 @@ describe('UpgradeCheckoutModal', () => { 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(paddle.Checkout.open.mock.calls[0][0].items[0].priceId).toBe('pri_payg_m'); expect(textOf(tree)).toContain('Use your own domain'); expect(textOf(tree)).toContain('Pay As You Go includes custom domains with HTTPS.'); }); it('sells the plan the usage banner names, MVP when it names none', async () => { const free = await mount( {}} />); - expect(paddle.Checkout.open.mock.calls[0][0].items[0].priceId).toBe('pri_mvp_y'); + expect(paddle.Checkout.open.mock.calls[0][0].items[0].priceId).toBe('pri_mvp_m'); expect(textOf(free)).toContain('Keep your app running'); paddle.Checkout.open.mockClear(); const mvp = await mount( {}} />); - expect(paddle.Checkout.open.mock.calls[0][0].items[0].priceId).toBe('pri_payg_y'); + expect(paddle.Checkout.open.mock.calls[0][0].items[0].priceId).toBe('pri_payg_m'); expect(textOf(mvp)).toContain('Pay As You Go raises your limits'); paddle.Checkout.open.mockClear(); const payg = await mount( {}} />); - expect(paddle.Checkout.open.mock.calls[0][0].items[0].priceId).toBe('pri_ded_y'); + expect(paddle.Checkout.open.mock.calls[0][0].items[0].priceId).toBe('pri_ded_m'); expect(textOf(payg)).toContain('Dedicated has unlimited requests'); }); 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'); + expect(paddle.Checkout.open.mock.calls[0][0].items[0].priceId).toBe('pri_payg_m'); paddle.Checkout.open.mockClear(); await mount( {}} />); - expect(paddle.Checkout.open.mock.calls[0][0].items[0].priceId).toBe('pri_ded_y'); + expect(paddle.Checkout.open.mock.calls[0][0].items[0].priceId).toBe('pri_ded_m'); }); }); @@ -270,11 +285,16 @@ describe('UpgradeGateButton', () => { initPaddle.mockImplementation(() => Promise.resolve(fakePaddle())); }); - it('logs the paywall view, then the click, and opens the checkout in place', async () => { + it('logs the view only for paywalls reached by trying the feature', async () => { + await mount(); + expect(loggedEvents('baas_upgrade_gate_viewed')).toEqual([{ gate: 'email_templates', app_id: 'app-1' }]); + }); + + it('a button always on screen logs only 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(loggedEvents('baas_upgrade_gate_viewed')).toEqual([]); expect(initPaddle).not.toHaveBeenCalled(); const preventDefault = jest.fn(); diff --git a/src/lib/tests/upgradeEvents.test.js b/src/lib/tests/upgradeEvents.test.js index 5f6992baa5..ee8621e676 100644 --- a/src/lib/tests/upgradeEvents.test.js +++ b/src/lib/tests/upgradeEvents.test.js @@ -10,7 +10,9 @@ const { UpgradeGateView, getGateFromSearch, logGateClicked, + paddleEventToTrack, planUsagePath, + trackPaddleEvent, } = require('../upgradeEvents'); describe('upgradeEvents', () => { @@ -48,4 +50,70 @@ describe('upgradeEvents', () => { it('keeps baas_ event names apart from the Agent upgrade_* events', () => { Object.values(UpgradeEvent).forEach(name => expect(name.startsWith('baas_')).toBe(true)); }); + + it('keeps the Paddle funnel steps under their own names, with the che_ id', () => { + expect(paddleEventToTrack({ name: 'checkout.loaded', data: { id: 'che_1' } }, new Set())).toEqual({ + eventType: 'baas_checkout_loaded', + properties: { checkout_id: 'che_1' }, + }); + expect( + paddleEventToTrack( + { + name: 'checkout.payment.failed', + data: { id: 'che_1' }, + error: { type: 'payment_error', code: 'declined', detail: 'Card declined' }, + }, + new Set() + ) + ).toEqual({ + eventType: 'baas_payment_failed', + properties: { checkout_id: 'che_1', error_type: 'payment_error', error_code: 'declined', error_detail: 'Card declined' }, + }); + }); + + it('sends every other Paddle event once, with the error Paddle showed', () => { + const seen = new Set(); + const error = { + name: 'checkout.error', + data: { id: 'che_1' }, + error: { type: 'validation_error', code: 'postcode_required', detail: 'Postcode is required' }, + }; + expect(paddleEventToTrack(error, seen)).toEqual({ + eventType: 'baas_checkout_event', + properties: { + name: 'checkout.error', + checkout_id: 'che_1', + error_type: 'validation_error', + error_code: 'postcode_required', + error_detail: 'Postcode is required', + }, + }); + expect(paddleEventToTrack(error, seen)).toBe(null); + // Same name, another error: a different thing the buyer saw. + const blocked = paddleEventToTrack({ type: 'checkout.error', error: { code: 'blocked', detail: 'x'.repeat(300) } }, seen); + expect(blocked.properties.name).toBe('checkout.error'); + expect(blocked.properties.error_detail).toHaveLength(200); + }); + + it('leaves out completion, close and the frame plumbing', () => { + const seen = new Set(); + ['checkout.completed', 'checkout.closed', 'checkout.ping.size', 'checkout.removespinner'].forEach(name => + expect(paddleEventToTrack({ name, data: { id: 'che_1' } }, seen)).toBe(null) + ); + expect(paddleEventToTrack({}, seen)).toBe(null); + }); + + it('logs a Paddle event with the checkout context, and nothing without one', () => { + const context = { app_id: 'app-1', plan: 'MVP', source: 'plan_page' }; + trackPaddleEvent({ name: 'checkout.payment.selected', data: { id: 'che_1' } }, context, new Set()); + expect(amplitudeLogEvent).toHaveBeenCalledWith('baas_checkout_event', { + ...context, + name: 'checkout.payment.selected', + checkout_id: 'che_1', + }); + + amplitudeLogEvent.mockClear(); + trackPaddleEvent({ name: 'checkout.loaded', data: { id: 'che_1' } }, undefined, new Set()); + expect(amplitudeLogEvent).not.toHaveBeenCalled(); + }); }); diff --git a/src/lib/upgradeEvents.js b/src/lib/upgradeEvents.js index 69977227ee..389ff34846 100644 --- a/src/lib/upgradeEvents.js +++ b/src/lib/upgradeEvents.js @@ -2,14 +2,79 @@ 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). +// gate viewed -> gate clicked -> checkout opened -> loaded -> payment initiated +// -> (failed | 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', + // What happens INSIDE the Paddle frame: without these the funnel can't tell + // "never saw the form" from "saw it and left" from "tried to pay and was declined". + CHECKOUT_LOADED: 'baas_checkout_loaded', + PAYMENT_INITIATED: 'baas_payment_initiated', + PAYMENT_FAILED: 'baas_payment_failed', + // Everything else Paddle reports from the frame, under Paddle's own `name`: an error + // or warning shown to the buyer, a checkout that failed to load, the method picked. + CHECKOUT_EVENT: 'baas_checkout_event', +}; + +const PADDLE_FUNNEL_EVENTS = { + 'checkout.loaded': UpgradeEvent.CHECKOUT_LOADED, + 'checkout.payment.initiated': UpgradeEvent.PAYMENT_INITIATED, + 'checkout.payment.failed': UpgradeEvent.PAYMENT_FAILED, +}; + +// Not sent as baas_checkout_event: the funnel steps above, completion and close (the +// callbacks handle them), and frame plumbing that says nothing about the buyer +// (`checkout.ping.size` fires on every resize). +const PADDLE_NOT_FORWARDED = new Set([ + ...Object.keys(PADDLE_FUNNEL_EVENTS), + 'checkout.completed', + 'checkout.closed', + 'checkout.ping.size', + 'checkout.removespinner', + 'checkout.frame.revealed', +]); + +// What one Paddle.js event sends to Amplitude, or null. The che_ id rides along (it is +// what Paddle support asks for), and so does Paddle's error message, cut short. `seen` +// belongs to the checkout open now: each name and error code goes out once. +// No card or email data is read. +export const paddleEventToTrack = (event, seen) => { + const name = event && (event.name || event.type); + if (!name) { + return null; + } + const checkoutId = event.data && event.data.id; + const errorFields = event.error + ? { + error_type: event.error.type, + error_code: event.error.code, + error_detail: event.error.detail ? event.error.detail.slice(0, 200) : event.error.detail, + } + : {}; + if (PADDLE_FUNNEL_EVENTS[name]) { + return { eventType: PADDLE_FUNNEL_EVENTS[name], properties: { checkout_id: checkoutId, ...errorFields } }; + } + const seenKey = `${name}|${(event.error && event.error.code) || ''}`; + if (PADDLE_NOT_FORWARDED.has(name) || seen.has(seenKey)) { + return null; + } + seen.add(seenKey); + return { eventType: UpgradeEvent.CHECKOUT_EVENT, properties: { name, checkout_id: checkoutId, ...errorFields } }; +}; + +// Logs a Paddle.js event with the funnel context of the checkout it belongs to. +export const trackPaddleEvent = (event, context, seen) => { + if (!context) { + return; + } + const tracked = paddleEventToTrack(event, seen); + if (tracked) { + amplitudeLogEvent(tracked.eventType, { ...context, ...tracked.properties }); + } }; // The paid feature that made the user hit the paywall. Also sent as ?gate= to plan-usage