From 15bc30b5964b4e22a94342804339b3033932b14d Mon Sep 17 00:00:00 2001 From: Malik Glossop Date: Wed, 12 Aug 2026 10:16:20 +0200 Subject: [PATCH] Merge pull request #36140 from overleaf/mg-ai-usage-limit-show Add 'ai-usage-limit-show' event for ai limits GitOrigin-RevId: 887ecc8b3fea52bcd45cf771ab0d22b8f4539308 --- .../components/ai-paywall-notification.tsx | 179 +++++++++++------- .../frontend/js/shared/utils/ai-features.ts | 22 +++ .../frontend/helpers/editor-providers.tsx | 3 + .../ai-paywall-notification.spec.tsx | 171 +++++++++++++++++ 4 files changed, 310 insertions(+), 65 deletions(-) create mode 100644 services/web/test/frontend/shared/components/ai-paywall-notification.spec.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 135ececa4e..2cbf52237b 100644 --- a/services/web/frontend/js/shared/components/ai-paywall-notification.tsx +++ b/services/web/frontend/js/shared/components/ai-paywall-notification.tsx @@ -7,14 +7,23 @@ import { useTranslation } from 'react-i18next' import { formatSecondsToHoursAndMinutes } from '@/shared/utils/time' import getMeta from '@/utils/meta' import { AiFeatureLocations } from './types/ai' -import { paywallTypeByLocation } from '../utils/ai-features' +import { + getAiPlanTypeWithoutUpgrade, + paywallTypeByLocation, +} from '../utils/ai-features' function AiPaywallNotification({ isActionBelowContent = false, featureLocation, + isVisible = true, }: { isActionBelowContent?: boolean featureLocation: AiFeatureLocations + /** + * Whether the notification is on screen. Prevents analytics firing for + * messages on mounted-but-hidden surfaces (e.g. the workbench rail). + */ + isVisible?: boolean }) { const { hasSuggestionsLeft, @@ -22,6 +31,7 @@ function AiPaywallNotification({ tokenResetDate, premiumSuggestionResetDate, } = useEditorContext() + const hasAddOn = getMeta('ol-hasUnlimitedAi') const user = getMeta('ol-user') @@ -32,8 +42,6 @@ function AiPaywallNotification({ return null } - const hasAddOn = getMeta('ol-hasUnlimitedAi') - // error assist only needs usage quota const canUseErrorAssist = hasSuggestionsLeft if (canUseErrorAssist && featureLocation === 'errorAssist') { @@ -68,6 +76,7 @@ function AiPaywallNotification({ ) } @@ -77,6 +86,7 @@ function AiPaywallNotification({ ) } @@ -86,6 +96,7 @@ function AiPaywallNotification({ ) } @@ -94,6 +105,55 @@ function AiPaywallNotification({ secondsTillReset={secondsTillReset} isActionBelowContent={isActionBelowContent} featureLocation={featureLocation} + isVisible={isVisible} + /> + ) +} + +// Shared by the variants that offer no way out of the limit. Reports +// `ai-usage-limit-show`, the no-CTA counterpart to UpgradePaywall's +// `paywall-prompt`, so the two events partition every limit message shown. +function AiUsageLimitNotification({ + title, + content, + featureLocation, + isVisible, +}: { + title: string + content: string + featureLocation: AiFeatureLocations + isVisible: boolean +}) { + const { sendEvent } = useEditorAnalytics() + const paywallType = paywallTypeByLocation[featureLocation] + const hasUnlimitedQuota = getMeta('ol-hasUnlimitedAi') + + useEffect(() => { + if (!isVisible) { + return + } + + const planType = getAiPlanTypeWithoutUpgrade(hasUnlimitedQuota) + if (!planType) { + return + } + + const { planCode } = getMeta('ol-user') + sendEvent('ai-usage-limit-show', { + 'paywall-type': paywallType, + 'plan-type': planType, + 'plan-code': planCode, + }) + }, [sendEvent, paywallType, isVisible, hasUnlimitedQuota]) + + return ( + ) } @@ -101,41 +161,38 @@ function AiPaywallNotification({ function GroupsPaywall({ secondsTillReset, featureLocation, + isVisible, }: { secondsTillReset: number featureLocation: AiFeatureLocations + isVisible: boolean }) { const { t } = useTranslation() - const message = t('your_limit_will_reset_in_time_or_speak_to_admin', { - time: formatSecondsToHoursAndMinutes(t, secondsTillReset), - }) - - const title = - featureLocation === 'workbench' - ? t('youve_reached_your_daily_ai_limit') - : t('youve_hit_your_daily_ai_limit') - return ( - <> - - + ) } function CommonsPaywall({ secondsTillReset, featureLocation, + isVisible, }: { secondsTillReset: number featureLocation: AiFeatureLocations + isVisible: boolean }) { const { t } = useTranslation() @@ -144,62 +201,49 @@ function CommonsPaywall({ return null } - const message = t('this_will_reset_in', { - time: formatSecondsToHoursAndMinutes(t, secondsTillReset), - }) - return ( - <> - - + ) } function FairUseLimit({ secondsTillReset, featureLocation, + isVisible, }: { secondsTillReset: number featureLocation: AiFeatureLocations + isVisible: boolean }) { const { t } = useTranslation() - - const title = - featureLocation === 'workbench' - ? t('usage_limit_reached') - : t('youve_reached_the_fair_usage') - - const workbenchMessage = t( - 'youve_reached_the_fair_usage_limit_on_your_plan_you_can_start_chatting_again_in_time', - { - time: formatSecondsToHoursAndMinutes(t, secondsTillReset), - } - ) - - const assistMessage = t('this_will_reset_in', { - time: formatSecondsToHoursAndMinutes(t, secondsTillReset), - }) - const message = - featureLocation === 'workbench' ? workbenchMessage : assistMessage + const isWorkbench = featureLocation === 'workbench' + const time = formatSecondsToHoursAndMinutes(t, secondsTillReset) return ( - <> - - + ) } @@ -207,20 +251,25 @@ function UpgradePaywall({ secondsTillReset, isActionBelowContent, featureLocation, + isVisible, }: { secondsTillReset: number isActionBelowContent: boolean featureLocation: AiFeatureLocations + isVisible: boolean }) { const { t } = useTranslation() const { sendEvent } = useEditorAnalytics() const paywallType = paywallTypeByLocation[featureLocation] useEffect(() => { + if (!isVisible) { + return + } sendEvent('paywall-prompt', { 'paywall-type': paywallType, }) - }, [sendEvent, paywallType]) + }, [sendEvent, paywallType, isVisible]) return ( = { @@ -5,3 +6,24 @@ export const paywallTypeByLocation: Record = { errorAssist: 'assistant', citationsReviewer: 'citations-reviewer', } + +// Returns the plan type for a limit with no upgrade path, or null when an +// upgrade is offered. Membership is checked before quota so commons/group users +// with unlimited AI aren't reported as individual; group before commons matches +// the rendered paywall's precedence for users who are in both. +export function getAiPlanTypeWithoutUpgrade( + hasUnlimitedQuota: boolean +): 'commons' | 'group' | 'individual' | null { + const { hasInstitutionLicence, isMemberOfGroupSubscription } = + getMeta('ol-user') + if (isMemberOfGroupSubscription) { + return 'group' + } + if (hasInstitutionLicence) { + return 'commons' + } + if (hasUnlimitedQuota) { + return 'individual' + } + return null +} diff --git a/services/web/test/frontend/helpers/editor-providers.tsx b/services/web/test/frontend/helpers/editor-providers.tsx index e9e2c4c2e6..535ef06e31 100644 --- a/services/web/test/frontend/helpers/editor-providers.tsx +++ b/services/web/test/frontend/helpers/editor-providers.tsx @@ -94,6 +94,9 @@ export type EditorProvidersProps = { | 'signUpDate' | 'activeProfessionalGroupSubscriptions' | 'isProfessionalGroupPlan' + | 'isMemberOfGroupSubscription' + | 'hasInstitutionLicence' + | 'planCode' > projectId?: string projectName?: string diff --git a/services/web/test/frontend/shared/components/ai-paywall-notification.spec.tsx b/services/web/test/frontend/shared/components/ai-paywall-notification.spec.tsx new file mode 100644 index 0000000000..ca23d934c2 --- /dev/null +++ b/services/web/test/frontend/shared/components/ai-paywall-notification.spec.tsx @@ -0,0 +1,171 @@ +import AiPaywallNotification from '@/shared/components/ai-paywall-notification' +import { AiFeatureLocations } from '@/shared/components/types/ai' +import { + EditorProviders, + makeEditorProvider, + USER_EMAIL, + USER_ID, + type EditorProvidersProps, +} from '../../helpers/editor-providers' + +type User = Partial> + +const futureDate = () => new Date(Date.now() + 60 * 60 * 1000) + +// These fixtures carry no planCode, so plan-type is the only thing identifying +// them in the event. +const COMMONS_USER: User = { hasInstitutionLicence: true } + +const GROUP_USER: User = { isMemberOfGroupSubscription: true } + +const ADD_ON_USER: User = { planCode: 'professional' } + +const FAIR_USE_TITLE = 'Usage limit reached' + +function mountPaywall({ + user = {}, + hasUnlimitedAi = false, + featureLocation = 'errorAssist', + isVisible = true, +}: { + user?: User + hasUnlimitedAi?: boolean + featureLocation?: AiFeatureLocations + isVisible?: boolean +} = {}) { + cy.window().then(win => { + win.metaAttributesCache.set('ol-showAiFeatures', true) + win.metaAttributesCache.set('ol-hasUnlimitedAi', hasUnlimitedAi) + win.metaAttributesCache.get('ol-ExposedSettings').isOverleaf = true + }) + + cy.mount( + + + + ) +} + +describe('', function () { + beforeEach(function () { + cy.intercept('POST', '/event/ai-usage-limit-show', { statusCode: 204 }).as( + 'limitShown' + ) + cy.intercept('POST', '/event/paywall-prompt', { statusCode: 204 }).as( + 'paywallPrompt' + ) + }) + + it('reports ai-usage-limit-show for a commons user', function () { + mountPaywall({ user: COMMONS_USER }) + + cy.wait('@limitShown').then(({ request }) => { + expect(request.body).to.deep.include({ + 'paywall-type': 'assistant', + 'plan-type': 'commons', + }) + expect(request.body).not.to.have.property('plan-code') + }) + }) + + // A commons entitlement can grant unlimited AI, which renders the fair-use + // copy. The user is still commons and must be reported as such. + it('reports a commons user as commons when their plan grants unlimited AI', function () { + mountPaywall({ + user: COMMONS_USER, + hasUnlimitedAi: true, + featureLocation: 'workbench', + }) + + cy.contains(FAIR_USE_TITLE).should('be.visible') + cy.wait('@limitShown').then(({ request }) => { + expect(request.body).to.deep.include({ + 'paywall-type': 'workbench', + 'plan-type': 'commons', + }) + }) + }) + + it('reports ai-usage-limit-show for a group member', function () { + mountPaywall({ user: GROUP_USER }) + + cy.wait('@limitShown').then(({ request }) => { + expect(request.body).to.deep.include({ + 'paywall-type': 'assistant', + 'plan-type': 'group', + }) + expect(request.body).not.to.have.property('plan-code') + }) + }) + + it('reports ai-usage-limit-show for a user on the AI add-on', function () { + mountPaywall({ + user: ADD_ON_USER, + hasUnlimitedAi: true, + featureLocation: 'workbench', + }) + + cy.wait('@limitShown').then(({ request }) => { + expect(request.body).to.deep.include({ + 'paywall-type': 'workbench', + 'plan-code': 'professional', + 'plan-type': 'individual', + }) + }) + }) + + it('reports paywall-prompt, not ai-usage-limit-show, when an upgrade is offered', function () { + mountPaywall({ user: { planCode: 'personal' } }) + + cy.wait('@paywallPrompt') + cy.get('@limitShown.all').should('have.length', 0) + }) + + // The workbench rail and its tab panes stay mounted while hidden, so the + // banner exists long before anyone sees it. + it('does not report a limit message the user cannot see', function () { + mountPaywall({ + user: COMMONS_USER, + featureLocation: 'workbench', + hasUnlimitedAi: true, + isVisible: false, + }) + + cy.contains(FAIR_USE_TITLE).should('be.visible') + cy.get('@limitShown.all').should('have.length', 0) + }) + + it('does not report a paywall prompt the user cannot see', function () { + mountPaywall({ + user: { planCode: 'personal' }, + featureLocation: 'workbench', + isVisible: false, + }) + + // Proves the upgrade variant rendered, so the absent event is the gate + // rather than the component having taken some other branch. + cy.findByRole('link', { name: 'Upgrade' }).should('be.visible') + cy.get('@paywallPrompt.all').should('have.length', 0) + }) + + it('reports nothing for a commons user in the workbench, where no message is shown', function () { + mountPaywall({ user: COMMONS_USER, featureLocation: 'workbench' }) + + cy.contains('You’ve reached your AI usage limit').should('not.exist') + cy.get('@limitShown.all').should('have.length', 0) + cy.get('@paywallPrompt.all').should('have.length', 0) + }) +})