diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index f010050171..08f538ed4a 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/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() } diff --git a/services/web/app/views/project/editor/meta.pug b/services/web/app/views/project/editor/meta.pug index 4c0d8a7fbe..a167ea3180 100644 --- a/services/web/app/views/project/editor/meta.pug +++ b/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) diff --git a/services/web/app/views/project/list/project-list.pug b/services/web/app/views/project/list/project-list.pug index bfdb87871c..74f86f4653 100644 --- a/services/web/app/views/project/list/project-list.pug +++ b/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( diff --git a/services/web/app/views/project/list/side-bar.pug b/services/web/app/views/project/list/side-bar.pug index 14a1fe75e4..37c0f1c4da 100644 --- a/services/web/app/views/project/list/side-bar.pug +++ b/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")}. diff --git a/services/web/frontend/js/features/editor-navigation-toolbar/components/toolbar-header.js b/services/web/frontend/js/features/editor-navigation-toolbar/components/toolbar-header.js index 9752b40380..63435dc5cc 100644 --- a/services/web/frontend/js/features/editor-navigation-toolbar/components/toolbar-header.js +++ b/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 && ( + + )} + {t('upgrade')} + + ) +} + +export default UpgradePrompt diff --git a/services/web/frontend/stylesheets/app/editor/toolbar.less b/services/web/frontend/stylesheets/app/editor/toolbar.less index 2196b47059..7d123876bd 100644 --- a/services/web/frontend/stylesheets/app/editor/toolbar.less +++ b/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%); diff --git a/services/web/test/unit/src/Project/ProjectControllerTests.js b/services/web/test/unit/src/Project/ProjectControllerTests.js index dfd16f6018..7e79d277d8 100644 --- a/services/web/test/unit/src/Project/ProjectControllerTests.js +++ b/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 () {