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 () {