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)
+ })
+})