Просмотр исходного кода

Add split test for persistent upgrade prompts project dash and editor header (#7590)

* Add Upgrade buttons on project dash and editor header

GitOrigin-RevId: 16325ffb2e63beeaff914e0b95db3faddcdf241a
Thomas 4 лет назад
Родитель
Сommit
8472ac6fc6

+ 72 - 2
services/web/app/src/Features/Project/ProjectController.js

@@ -40,6 +40,7 @@ const FeaturesUpdater = require('../Subscription/FeaturesUpdater')
 const SpellingHandler = require('../Spelling/SpellingHandler')
 const UserPrimaryEmailCheckHandler = require('../User/UserPrimaryEmailCheckHandler')
 const { hasAdminAccess } = require('../Helpers/AdminAuthorizationHelper')
+const InstitutionsFeatures = require('../Institutions/InstitutionsFeatures')
 
 const _ssoAvailable = (affiliation, session, linkedInstitutionIds) => {
   if (!affiliation.institution) return false
@@ -458,14 +459,38 @@ const ProjectController = {
             }
           )
         },
+
+        persistentUpgradePromptsAssignment(cb) {
+          SplitTestHandler.getAssignment(
+            req,
+            res,
+            'persistent-upgrade-prompt',
+            (err, assignment) => {
+              if (err) {
+                logger.warn(
+                  { err },
+                  'failed to get "persistent-upgrade-prompt" split test assignment'
+                )
+                cb(null, { variant: 'default' })
+              } else {
+                cb(null, assignment)
+              }
+            }
+          )
+        },
       },
       (err, results) => {
         if (err != null) {
           OError.tag(err, 'error getting data for project list page')
           return next(err)
         }
-        const { notifications, user, userEmailsData, primaryEmailCheckActive } =
-          results
+        const {
+          notifications,
+          user,
+          userEmailsData,
+          primaryEmailCheckActive,
+          persistentUpgradePromptsAssignment,
+        } = results
 
         if (
           user &&
@@ -594,6 +619,12 @@ const ProjectController = {
           )
         }
 
+        // Persistent upgrade prompts
+        const showToolbarUpgradePrompt =
+          persistentUpgradePromptsAssignment.variant === 'persistent-upgrade' &&
+          !results.hasSubscription &&
+          !userEmails.some(e => e.emailHasInstitutionLicence)
+
         ProjectController._injectProjectUsers(projects, (error, projects) => {
           if (error != null) {
             return next(error)
@@ -616,6 +647,7 @@ const ProjectController = {
             isOverleaf: !!Settings.overleaf,
             metadata: { viewport: false },
             showThinFooter: true, // don't show the fat footer on the projects dashboard, as there's a fixed space available
+            showToolbarUpgradePrompt,
           }
 
           const paidUser =
@@ -720,6 +752,18 @@ const ProjectController = {
             )
           }
         },
+        userHasInstitutionLicence(cb) {
+          if (!userId) {
+            return cb(null, false)
+          }
+          InstitutionsFeatures.hasLicence(userId, (error, hasLicence) => {
+            if (error) {
+              // Don't fail if we can't get affiliation licences
+              return cb(null, false)
+            }
+            cb(null, hasLicence)
+          })
+        },
         learnedWords(cb) {
           if (!userId) {
             return cb(null, [])
@@ -838,12 +882,28 @@ const ProjectController = {
             }
           )
         },
+        persistentUpgradePromptsAssignment(cb) {
+          SplitTestHandler.getAssignment(
+            req,
+            res,
+            'persistent-upgrade-prompt',
+            (error, assignment) => {
+              // do not fail editor load if assignment fails
+              if (error) {
+                cb(null, { variant: 'default' })
+              } else {
+                cb(null, assignment)
+              }
+            }
+          )
+        },
       },
       (
         err,
         {
           project,
           user,
+          userHasInstitutionLicence,
           learnedWords,
           subscription,
           isTokenMember,
@@ -851,6 +911,7 @@ const ProjectController = {
           newSourceEditorAssignment,
           pdfDetachAssignment,
           pdfjsAssignment,
+          persistentUpgradePromptsAssignment,
         }
       ) => {
         if (err != null) {
@@ -956,6 +1017,14 @@ const ProjectController = {
               !Features.hasFeature('saas') ||
               (user.features && user.features.symbolPalette)
 
+            // Persistent upgrade prompts
+            const showHeaderUpgradePrompt =
+              persistentUpgradePromptsAssignment.variant ===
+                'persistent-upgrade' &&
+              userId &&
+              !subscription &&
+              !userHasInstitutionLicence
+
             const template =
               detachRole === 'detached'
                 ? 'project/editor_detached'
@@ -1027,6 +1096,7 @@ const ProjectController = {
                 !shouldDisplayFeature('enable_pdf_caching', false),
               detachRole,
               metadata: { viewport: false },
+              showHeaderUpgradePrompt,
             })
             timer.done()
           }

+ 1 - 0
services/web/app/views/project/editor/meta.pug

@@ -30,6 +30,7 @@ meta(name="ol-enablePdfCaching" data-type="boolean" content=enablePdfCaching)
 meta(name="ol-trackPdfDownload" data-type="boolean" content=trackPdfDownload)
 meta(name="ol-resetServiceWorker" data-type="boolean" content=resetServiceWorker)
 meta(name="ol-detachRole" data-type="string" content=detachRole)
+meta(name="ol-showHeaderUpgradePrompt" data-type="boolean" content=showHeaderUpgradePrompt)
 
 - var fileActionI18n = ['edited', 'renamed', 'created', 'deleted'].reduce((acc, i) => {acc[i] = translate('file_action_' + i); return acc}, {})
 meta(name="ol-fileActionI18n" data-type="json" content=fileActionI18n)

+ 14 - 0
services/web/app/views/project/list/project-list.pug

@@ -23,7 +23,21 @@
 					ng-show="searchText.value.length > 0"
 				) #{translate('clear_search')}
 
+
 		.project-tools(ng-cloak)
+			if (showToolbarUpgradePrompt)
+				.project-list-upgrade-prompt(ng-cloak ng-hide="selectedProjects.length > 0")
+					span You're on the free plan  
+					a.btn.btn-primary(
+						href="/user/subscription/plans"
+						event-tracking="upgrade-button-click"
+						event-tracking-mb="true"
+						event-tracking-ga="subscription-funnel"
+						event-tracking-action="dashboard-top"
+						event-tracking-label="upgrade"
+						event-tracking-trigger="click"
+						event-segmentation='{"source": "dashboard-top"}'
+					) Upgrade
 			.btn-toolbar
 				.btn-group(ng-hide="selectedProjects.length < 1")
 					a.btn.btn-default(

+ 9 - 8
services/web/app/views/project/list/side-bar.pug

@@ -134,11 +134,12 @@ if (isOverleaf)
 						href="/user/settings"
 					) Add Affiliation
 
-		.row-spaced(ng-if="hasProjects && userHasNoSubscription && !userOnPayingUniversity", ng-cloak).text-centered
-			hr
-			p.small #{translate("on_free_sl")}
-			p
-				a(href="/user/subscription/plans" ng-click="upgradeSubscription()").btn.btn-primary #{translate("upgrade")}
-			p.small.text-centered
-				| #{translate("or_unlock_features_bonus")} 
-				a(href="/user/bonus" ng-click="share()") #{translate("sharing_sl")}.
+		unless (showToolbarUpgradePrompt)
+			.row-spaced(ng-if="hasProjects && userHasNoSubscription && !userOnPayingUniversity", ng-cloak).text-centered
+				hr
+				p.small #{translate("on_free_sl")}
+				p
+					a(href="/user/subscription/plans" ng-click="upgradeSubscription()").btn.btn-primary #{translate("upgrade")}
+				p.small.text-centered
+					| #{translate("or_unlock_features_bonus")} 
+					a(href="/user/bonus" ng-click="share()") #{translate("sharing_sl")}.

+ 4 - 0
services/web/frontend/js/features/editor-navigation-toolbar/components/toolbar-header.js

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
 import MenuButton from './menu-button'
 import CobrandingLogo from './cobranding-logo'
 import BackToProjectsButton from './back-to-projects-button'
+import UpgradePrompt from './upgrade-prompt'
 import ChatToggleButton from './chat-toggle-button'
 import LayoutDropdownButton from './layout-dropdown-button'
 import OnlineUsersWidget from './online-users-widget'
@@ -64,6 +65,9 @@ const ToolbarHeader = React.memo(function ToolbarHeader({
           pdfViewIsOpen={pdfViewIsOpen}
         />
       )}
+      {window.showHeaderUpgradePrompt && !pdfButtonIsVisible && (
+        <UpgradePrompt />
+      )}
       <ProjectNameEditableLabel
         className="toolbar-center"
         projectName={projectName}

+ 24 - 0
services/web/frontend/js/features/editor-navigation-toolbar/components/upgrade-prompt.js

@@ -0,0 +1,24 @@
+import { useTranslation } from 'react-i18next'
+import * as eventTracking from '../../../infrastructure/event-tracking'
+
+function UpgradePrompt() {
+  const { t } = useTranslation()
+
+  function handleClick(e) {
+    eventTracking.send('subscription-funnel', 'code-editor', 'upgrade')
+    eventTracking.sendMB('upgrade-button-click', { source: 'code-editor' })
+  }
+
+  return (
+    <a
+      className="toolbar-header-upgrade-prompt btn btn-primary btn-xs"
+      href="/user/subscription/plans"
+      target="_blank"
+      onClick={handleClick}
+    >
+      {t('upgrade')}
+    </a>
+  )
+}
+
+export default UpgradePrompt

+ 6 - 0
services/web/frontend/stylesheets/app/editor/toolbar.less

@@ -222,6 +222,12 @@
   }
 }
 
+.toolbar-header-upgrade-prompt {
+  @media (max-width: @screen-md-min) {
+    display: none;
+  }
+}
+
 .editor-dark {
   .toolbar-alt {
     background-color: darken(@editor-dark-background-color, 0%);

+ 108 - 0
services/web/test/unit/src/Project/ProjectControllerTests.js

@@ -129,6 +129,9 @@ describe('ProjectController', function () {
       },
       getAssignment: sinon.stub().yields(null, { variant: 'default' }),
     }
+    this.InstitutionsFeatures = {
+      hasLicence: sinon.stub().callsArgWith(1, null, false),
+    }
 
     this.ProjectController = SandboxedModule.require(MODULE_PATH, {
       requires: {
@@ -171,6 +174,7 @@ describe('ProjectController', function () {
         '../Spelling/SpellingHandler': {
           getUserDictionary: sinon.stub().yields(null, []),
         },
+        '../Institutions/InstitutionsFeatures': this.InstitutionsFeatures,
       },
     })
 
@@ -546,6 +550,57 @@ describe('ProjectController', function () {
       })
     })
 
+    describe('persistent upgrade prompt', function () {
+      describe('if the user has the default variant', function (done) {
+        it('should not show', function (done) {
+          this.res.render = (pageName, opts) => {
+            expect(opts.showToolbarUpgradePrompt).to.equal(false)
+            done()
+          }
+          this.ProjectController.projectListPage(this.req, this.res)
+        })
+      })
+
+      describe('if the user has the persistent-upgrade variant', function (done) {
+        beforeEach(function () {
+          this.SplitTestHandler.getAssignment
+            .withArgs(this.req, this.res, 'persistent-upgrade-prompt')
+            .yields(null, { variant: 'persistent-upgrade' })
+        })
+        it('should show for a user without a subscription or only non-paid affiliations', function (done) {
+          this.res.render = (pageName, opts) => {
+            expect(opts.showToolbarUpgradePrompt).to.equal(true)
+            done()
+          }
+          this.ProjectController.projectListPage(this.req, this.res)
+        })
+        it('should not show for a user with a subscription', function (done) {
+          this.LimitationsManager.hasPaidSubscription = sinon
+            .stub()
+            .callsArgWith(1, null, true)
+          this.res.render = (pageName, opts) => {
+            expect(opts.showToolbarUpgradePrompt).to.equal(false)
+            done()
+          }
+          this.ProjectController.projectListPage(this.req, this.res)
+        })
+        it('should not show for a user with an affiliated paid university', function (done) {
+          const emailWithProAffiliation = {
+            email: 'pro@example.com',
+            emailHasInstitutionLicence: true,
+          }
+          this.UserGetter.getUserFullEmails = sinon
+            .stub()
+            .yields(null, [emailWithProAffiliation])
+          this.res.render = (pageName, opts) => {
+            expect(opts.showToolbarUpgradePrompt).to.equal(false)
+            done()
+          }
+          this.ProjectController.projectListPage(this.req, this.res)
+        })
+      })
+    })
+
     describe('With Institution SSO feature', function () {
       beforeEach(function (done) {
         this.institutionEmail = 'test@overleaf.com'
@@ -1441,6 +1496,59 @@ describe('ProjectController', function () {
         })
       })
     })
+
+    describe('persistent upgrade prompt', function () {
+      beforeEach(function () {
+        // default to without a subscription
+        this.SubscriptionLocator.getUsersSubscription = sinon
+          .stub()
+          .callsArgWith(1, null, null)
+      })
+      describe('if the user has the default variant', function (done) {
+        it('should not show', function (done) {
+          this.res.render = (pageName, opts) => {
+            expect(opts.showHeaderUpgradePrompt).to.equal(false)
+            done()
+          }
+          this.ProjectController.loadEditor(this.req, this.res)
+        })
+      })
+
+      describe('if the user has the persistent-upgrade variant', function (done) {
+        beforeEach(function () {
+          this.SplitTestHandler.getAssignment
+            .withArgs(this.req, this.res, 'persistent-upgrade-prompt')
+            .yields(null, { variant: 'persistent-upgrade' })
+        })
+        it('should show for a user without a subscription or only non-paid affiliations', function (done) {
+          this.res.render = (pageName, opts) => {
+            expect(opts.showHeaderUpgradePrompt).to.equal(true)
+            done()
+          }
+          this.ProjectController.loadEditor(this.req, this.res)
+        })
+        it('should not show for a user with a subscription', function (done) {
+          this.SubscriptionLocator.getUsersSubscription = sinon
+            .stub()
+            .callsArgWith(1, null, {})
+          this.res.render = (pageName, opts) => {
+            expect(opts.showHeaderUpgradePrompt).to.equal(false)
+            done()
+          }
+          this.ProjectController.loadEditor(this.req, this.res)
+        })
+        it('should not show for a user with an affiliated paid university', function (done) {
+          this.InstitutionsFeatures.hasLicence = sinon
+            .stub()
+            .callsArgWith(1, null, true)
+          this.res.render = (pageName, opts) => {
+            expect(opts.showHeaderUpgradePrompt).to.equal(false)
+            done()
+          }
+          this.ProjectController.loadEditor(this.req, this.res)
+        })
+      })
+    })
   })
 
   describe('userProjectsJson', function () {