From fa26367aa91ac2eac18ed9c811f3ea2d7da64f1a Mon Sep 17 00:00:00 2001 From: Malik Glossop Date: Wed, 27 May 2026 11:55:03 +0200 Subject: [PATCH] Merge pull request #33805 from overleaf/mg-ai-paywall-analytics Add paywall-prompt and paywall-click events to ai paywalls GitOrigin-RevId: aa7de15a990ad1833e3dda65d5fb50f60bb7c9e3 --- .../components/ai-paywall-notification.tsx | 88 +++++++++++++------ .../components/paywall-upgrade-button.tsx | 40 +++++++++ .../paywall-upgrade-button.test.tsx | 60 +++++++++++++ 3 files changed, 160 insertions(+), 28 deletions(-) create mode 100644 services/web/frontend/js/shared/components/paywall-upgrade-button.tsx create mode 100644 services/web/test/frontend/shared/components/paywall-upgrade-button.test.tsx diff --git a/services/web/frontend/js/shared/components/ai-paywall-notification.tsx b/services/web/frontend/js/shared/components/ai-paywall-notification.tsx index 809821b05a..0ec6b17b86 100644 --- a/services/web/frontend/js/shared/components/ai-paywall-notification.tsx +++ b/services/web/frontend/js/shared/components/ai-paywall-notification.tsx @@ -1,7 +1,9 @@ +import { useEffect } from 'react' import Notification from '@/shared/components/notification' -import UpgradeButton from '@/features/ide-react/components/toolbar/upgrade-button' +import PaywallUpgradeButton from '@/shared/components/paywall-upgrade-button' import { useEditorContext } from '@/shared/context/editor-context' import { useUserFeaturesContext } from '@/shared/context/user-features-context' +import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics' import { useTranslation } from 'react-i18next' import { formatSecondsToHoursAndMinutes } from '@/shared/utils/time' import { useFeatureFlag } from '@/shared/context/split-test-context' @@ -9,14 +11,20 @@ import { useFeatureFlag } from '@/shared/context/split-test-context' import getMeta from '@/utils/meta' const hasUnlimitedAi = getMeta('ol-hasUnlimitedAi') -type aiFeatureLocations = 'errorAssist' | 'workbench' +type AiFeatureLocations = 'errorAssist' | 'workbench' +type PaywallType = 'assistant' | 'workbench' + +const paywallTypeByLocation: Record = { + workbench: 'workbench', + errorAssist: 'assistant', +} function AiPaywallNotification({ isActionBelowContent = false, featureLocation, }: { isActionBelowContent?: boolean - featureLocation: aiFeatureLocations + featureLocation: AiFeatureLocations }) { const { hasSuggestionsLeft, @@ -123,29 +131,12 @@ function AiPaywallNotification({ /> ) } - - const message = t('upgrade_for_unlimited_access_to_ai', { - time: formatSecondsToHoursAndMinutes(t, secondsTillReset), - }) return ( - <> - - } - className="ai-upgrade-paywall-btn ai-paywall-notification" - /> - + ) } @@ -154,7 +145,7 @@ function GroupsPaywall({ featureLocation, }: { secondsTillReset: number - featureLocation: aiFeatureLocations + featureLocation: AiFeatureLocations }) { const { t } = useTranslation() @@ -186,7 +177,7 @@ function CommonsPaywall({ featureLocation, }: { secondsTillReset: number - featureLocation: aiFeatureLocations + featureLocation: AiFeatureLocations }) { const { t } = useTranslation() @@ -218,7 +209,7 @@ function FairUseLimit({ featureLocation, }: { secondsTillReset: number - featureLocation: aiFeatureLocations + featureLocation: AiFeatureLocations }) { const { t } = useTranslation() @@ -254,4 +245,45 @@ function FairUseLimit({ ) } +function UpgradePaywall({ + secondsTillReset, + isActionBelowContent, + featureLocation, +}: { + secondsTillReset: number + isActionBelowContent: boolean + featureLocation: AiFeatureLocations +}) { + const { t } = useTranslation() + const { sendEvent } = useEditorAnalytics() + const paywallType = paywallTypeByLocation[featureLocation] + + useEffect(() => { + sendEvent('paywall-prompt', { + 'paywall-type': paywallType, + }) + }, [sendEvent, paywallType]) + + return ( + + } + className="ai-upgrade-paywall-btn ai-paywall-notification" + /> + ) +} + export default AiPaywallNotification diff --git a/services/web/frontend/js/shared/components/paywall-upgrade-button.tsx b/services/web/frontend/js/shared/components/paywall-upgrade-button.tsx new file mode 100644 index 0000000000..7e5b81d2a3 --- /dev/null +++ b/services/web/frontend/js/shared/components/paywall-upgrade-button.tsx @@ -0,0 +1,40 @@ +import OLButton from '@/shared/components/ol/ol-button' +import getMeta from '@/utils/meta' +import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics' +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' + +export default function PaywallUpgradeButton({ + referrer, + paywallType, + className, +}: { + referrer: string + paywallType: string + className?: string +}) { + const { t } = useTranslation() + const { sendEvent } = useEditorAnalytics() + const user = getMeta('ol-user') + + const handleClick = useCallback(() => { + sendEvent('paywall-click', { + upgradeType: user.hasPaidSubscription ? 'add-on' : 'standalone', + 'paywall-type': paywallType, + }) + }, [sendEvent, user.hasPaidSubscription, paywallType]) + + return ( + + {t('upgrade')} + + ) +} diff --git a/services/web/test/frontend/shared/components/paywall-upgrade-button.test.tsx b/services/web/test/frontend/shared/components/paywall-upgrade-button.test.tsx new file mode 100644 index 0000000000..0013422f58 --- /dev/null +++ b/services/web/test/frontend/shared/components/paywall-upgrade-button.test.tsx @@ -0,0 +1,60 @@ +import { expect } from 'chai' +import sinon from 'sinon' +import { screen, render } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import PaywallUpgradeButton from '../../../../frontend/js/shared/components/paywall-upgrade-button' +import * as eventTracking from '@/infrastructure/event-tracking' + +describe('', function () { + let sendMBSpy: sinon.SinonSpy + + beforeEach(function () { + sendMBSpy = sinon.spy(eventTracking, 'sendMB') + window.metaAttributesCache.set('ol-user', { hasPaidSubscription: false }) + }) + + afterEach(function () { + sendMBSpy.restore() + }) + + it('upgrade link has required attributes', function () { + render() + + const upgradeLink = screen.getByRole('link', { name: 'Upgrade' }) + expect(upgradeLink.getAttribute('href')).to.equal( + '/user/subscription/choose-your-plan?itm_referrer=ai&itm_campaign=assistant' + ) + expect(upgradeLink.getAttribute('target')).to.equal('_blank') + expect(upgradeLink.getAttribute('rel')).to.equal('noreferrer') + }) + + it('sends paywall-click with standalone upgradeType for users without a paid subscription', async function () { + render() + + await userEvent.click(screen.getByRole('link', { name: 'Upgrade' })) + + expect(sendMBSpy).to.have.been.calledOnceWith( + 'paywall-click', + sinon.match({ + upgradeType: 'standalone', + 'paywall-type': 'assistant', + }) + ) + }) + + it('sends paywall-click with add-on upgradeType for users with a paid subscription', async function () { + window.metaAttributesCache.set('ol-user', { hasPaidSubscription: true }) + + render() + + await userEvent.click(screen.getByRole('link', { name: 'Upgrade' })) + + expect(sendMBSpy).to.have.been.calledOnceWith( + 'paywall-click', + sinon.match({ + upgradeType: 'add-on', + 'paywall-type': 'workbench', + }) + ) + }) +})