Merge pull request #33805 from overleaf/mg-ai-paywall-analytics

Add paywall-prompt and paywall-click events to ai paywalls

GitOrigin-RevId: aa7de15a990ad1833e3dda65d5fb50f60bb7c9e3
This commit is contained in:
Malik Glossop
2026-05-27 11:55:03 +02:00
committed by Copybot
parent 8af5c2c346
commit fa26367aa9
3 changed files with 160 additions and 28 deletions
@@ -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<AiFeatureLocations, PaywallType> = {
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 (
<>
<Notification
type="info"
title={t('youve_hit_your_daily_ai_limit')}
content={message}
isDismissible={false}
customIcon={null}
isActionBelowContent={isActionBelowContent}
action={
<UpgradeButton
className="px-2.5 py-2"
referrer="ai"
source={featureLocation}
/>
}
className="ai-upgrade-paywall-btn ai-paywall-notification"
/>
</>
<UpgradePaywall
secondsTillReset={secondsTillReset}
isActionBelowContent={isActionBelowContent}
featureLocation={featureLocation}
/>
)
}
@@ -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 (
<Notification
type="info"
title={t('youve_hit_your_daily_ai_limit')}
content={t('upgrade_for_unlimited_access_to_ai', {
time: formatSecondsToHoursAndMinutes(t, secondsTillReset),
})}
isDismissible={false}
customIcon={null}
isActionBelowContent={isActionBelowContent}
action={
<PaywallUpgradeButton
referrer="ai"
paywallType={paywallType}
className="px-2.5 py-2"
/>
}
className="ai-upgrade-paywall-btn ai-paywall-notification"
/>
)
}
export default AiPaywallNotification
@@ -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 (
<OLButton
variant="premium"
size="sm"
href={`/user/subscription/choose-your-plan?itm_referrer=${referrer}&itm_campaign=${paywallType}`}
target="_blank"
rel="noreferrer"
className={className}
onClick={handleClick}
>
{t('upgrade')}
</OLButton>
)
}
@@ -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('<PaywallUpgradeButton />', 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(<PaywallUpgradeButton referrer="ai" paywallType="assistant" />)
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(<PaywallUpgradeButton referrer="ai" paywallType="assistant" />)
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(<PaywallUpgradeButton referrer="ai" paywallType="workbench" />)
await userEvent.click(screen.getByRole('link', { name: 'Upgrade' }))
expect(sendMBSpy).to.have.been.calledOnceWith(
'paywall-click',
sinon.match({
upgradeType: 'add-on',
'paywall-type': 'workbench',
})
)
})
})