diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js
index f204c40e69..fb79bfcdf2 100644
--- a/services/web/app/src/Features/Project/ProjectController.js
+++ b/services/web/app/src/Features/Project/ProjectController.js
@@ -517,6 +517,41 @@ const ProjectController = {
}
)
},
+ userIsMemberOfGroupSubscription(cb) {
+ LimitationsManager.userIsMemberOfGroupSubscription(
+ currentUser,
+ (error, isMember) => {
+ if (error) {
+ logger.error(
+ { err: error },
+ 'Failed to check whether user is a member of group subscription'
+ )
+ return cb(null, false)
+ }
+ cb(null, isMember)
+ }
+ )
+ },
+ groupsAndEnterpriseBannerAssignment(cb) {
+ SplitTestHandler.getAssignment(
+ req,
+ res,
+ 'groups-and-enterprise-banner',
+ (err, assignment) => {
+ if (err) {
+ logger.warn(
+ { err },
+ 'failed to get "groups-and-enterprise-banner" split test assignment'
+ )
+
+ const defaultAssignment = { variant: 'default' }
+ cb(null, defaultAssignment)
+ } else {
+ cb(null, assignment)
+ }
+ }
+ )
+ },
primaryEmailCheckActive(cb) {
SplitTestHandler.getAssignment(
req,
@@ -552,8 +587,14 @@ const ProjectController = {
OError.tag(err, 'error getting data for project list page')
return next(err)
}
- const { notifications, user, userEmailsData, primaryEmailCheckActive } =
- results
+ const {
+ notifications,
+ user,
+ userEmailsData,
+ primaryEmailCheckActive,
+ groupsAndEnterpriseBannerAssignment,
+ userIsMemberOfGroupSubscription,
+ } = results
if (
user &&
@@ -682,6 +723,17 @@ const ProjectController = {
)
}
+ const hasPaidAffiliation = userAffiliations.some(
+ affiliation => affiliation.licence && affiliation.licence !== 'free'
+ )
+
+ // groupsAndEnterpriseBannerAssignment.variant = 'default' | 'empower' | 'save' | 'did-you-know'
+ const showGroupsAndEnterpriseBanner =
+ groupsAndEnterpriseBannerAssignment.variant !== 'default' &&
+ Features.hasFeature('saas') &&
+ !userIsMemberOfGroupSubscription &&
+ !hasPaidAffiliation
+
ProjectController._injectProjectUsers(projects, (error, projects) => {
if (error != null) {
return next(error)
@@ -706,6 +758,9 @@ const ProjectController = {
showThinFooter: true, // don't show the fat footer on the projects dashboard, as there's a fixed space available
usersBestSubscription: results.usersBestSubscription,
survey: results.survey,
+ showGroupsAndEnterpriseBanner,
+ groupsAndEnterpriseBannerVariant:
+ groupsAndEnterpriseBannerAssignment.variant,
}
const paidUser =
diff --git a/services/web/app/src/Features/Project/ProjectListController.js b/services/web/app/src/Features/Project/ProjectListController.js
index 9e0a05ee14..26d4c2e105 100644
--- a/services/web/app/src/Features/Project/ProjectListController.js
+++ b/services/web/app/src/Features/Project/ProjectListController.js
@@ -20,6 +20,7 @@ const { User } = require('../../models/User')
const SplitTestHandler = require('../SplitTests/SplitTestHandler')
const UserPrimaryEmailCheckHandler = require('../User/UserPrimaryEmailCheckHandler')
const UserController = require('../User/UserController')
+const LimitationsManager = require('../Subscription/LimitationsManager')
/** @typedef {import("./types").GetProjectsRequest} GetProjectsRequest */
/** @typedef {import("./types").GetProjectsResponse} GetProjectsResponse */
@@ -287,6 +288,48 @@ async function projectListReactPage(req, res, next) {
status: prefetchedProjectsBlob ? 'success' : 'too-slow',
})
+ let showGroupsAndEnterpriseBanner = false
+ let groupsAndEnterpriseBannerAssignment
+
+ try {
+ groupsAndEnterpriseBannerAssignment =
+ await SplitTestHandler.promises.getAssignment(
+ req,
+ res,
+ 'groups-and-enterprise-banner'
+ )
+ } catch (error) {
+ logger.error(
+ { err: error },
+ 'failed to get "groups-and-enterprise-banner" split test assignment'
+ )
+ }
+
+ let userIsMemberOfGroupSubscription = false
+
+ try {
+ const userIsMemberOfGroupSubscriptionPromise =
+ await LimitationsManager.promises.userIsMemberOfGroupSubscription(user)
+
+ userIsMemberOfGroupSubscription =
+ userIsMemberOfGroupSubscriptionPromise.isMember
+ } catch (error) {
+ logger.error(
+ { err: error },
+ 'Failed to check whether user is a member of group subscription'
+ )
+ }
+
+ const hasPaidAffiliation = userAffiliations.some(
+ affiliation => affiliation.licence && affiliation.licence !== 'free'
+ )
+
+ showGroupsAndEnterpriseBanner =
+ (groupsAndEnterpriseBannerAssignment?.variant ?? 'default') !== 'default' &&
+ Features.hasFeature('saas') &&
+ !userIsMemberOfGroupSubscription &&
+ !hasPaidAffiliation
+
res.render('project/list-react', {
title: 'your_projects',
usersBestSubscription,
@@ -301,6 +344,9 @@ async function projectListReactPage(req, res, next) {
tags,
portalTemplates,
prefetchedProjectsBlob,
+ showGroupsAndEnterpriseBanner,
+ groupsAndEnterpriseBannerVariant:
+ groupsAndEnterpriseBannerAssignment?.variant ?? 'default',
})
}
diff --git a/services/web/app/views/project/list-react.pug b/services/web/app/views/project/list-react.pug
index cac3ebd9ac..ca30d22469 100644
--- a/services/web/app/views/project/list-react.pug
+++ b/services/web/app/views/project/list-react.pug
@@ -25,6 +25,8 @@ block append meta
imgUrl: buildImgPath("flags/24/" + suggestedLanguageSubdomainConfig.lngCode + ".png")
}))
meta(name="ol-currentUrl" data-type="string" content=currentUrl)
+ meta(name="ol-showGroupsAndEnterpriseBanner" data-type="boolean" content=showGroupsAndEnterpriseBanner)
+ meta(name="ol-groupsAndEnterpriseBannerVariant" data-type="string" content=groupsAndEnterpriseBannerVariant)
block content
main.content.content-alt.project-list-react#project-list-root
diff --git a/services/web/app/views/project/list.pug b/services/web/app/views/project/list.pug
index e7758a0776..bdb9dac3aa 100644
--- a/services/web/app/views/project/list.pug
+++ b/services/web/app/views/project/list.pug
@@ -14,6 +14,7 @@ block append meta
meta(name="ol-allInReconfirmNotificationPeriods" data-type="json" content=allInReconfirmNotificationPeriods)
meta(name="ol-reconfirmedViaSAML" content=reconfirmedViaSAML)
meta(name="ol-survey-name" data-type="string" content=(survey ? survey.name : undefined))
+ meta(name="ol-groupsAndEnterpriseBannerVariant" data-type="string" content=groupsAndEnterpriseBannerVariant)
block content
diff --git a/services/web/app/views/project/list/notifications.pug b/services/web/app/views/project/list/notifications.pug
index a8672d9c36..d7cba28192 100644
--- a/services/web/app/views/project/list/notifications.pug
+++ b/services/web/app/views/project/list/notifications.pug
@@ -251,3 +251,35 @@ include ../../_mixins/reconfirm_affiliation
ng-if="userEmail.samlIdentifier && userEmail.samlIdentifier.providerId === reconfirmedViaSAML"
)
+reconfirmedAffiliationNotification()
+
+ if showGroupsAndEnterpriseBanner
+ - var eventSegmentation = '{"location": "dashboard-banner", "variant":"' + groupsAndEnterpriseBannerVariant + '" }'
+ ul.list-unstyled(
+ ng-controller="GroupsAndEnterpriseBannerController",
+ ng-cloak
+ )
+ li.notification-entry(
+ ng-if="isVariantValid && !hasDismissedGroupsAndEnterpriseBanner && projects.length > 0"
+ event-tracking="groups-and-enterprise-banner-prompt"
+ event-tracking-mb="true"
+ event-tracking-trigger="load"
+ event-segmentation=eventSegmentation
+ )
+ .alert.alert-info
+ .notification-body(ng-switch="groupsAndEnterpriseBannerVariant")
+ span(ng-switch-when="empower") #{translate("empower_your_organization_to_work_in_overleaf")}
+ span(ng-switch-when="save") !{translate("save_money_groups_companies_research_organizations_can_save_money", {}, ['strong'])}
+ span(ng-switch-when="did-you-know") #{translate("did_you_know_that_overleaf_offers")}
+ .notification-action
+ a.pull-right.btn.btn-sm.btn-info(
+ href="/for/contact-sales"
+ target="_blank"
+ event-tracking="groups-and-enterprise-banner-click"
+ event-tracking-mb="true"
+ event-tracking-trigger="click"
+ event-segmentation=eventSegmentation
+ ) #{translate("contact_sales")}
+ .notification-close
+ button(ng-click="dismiss()").close.pull-right
+ span(aria-hidden="true") ×
+ span.sr-only #{translate("close")}
diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json
index 499b51298e..cb7d471ec1 100644
--- a/services/web/frontend/extracted-translations.json
+++ b/services/web/frontend/extracted-translations.json
@@ -107,6 +107,7 @@
"conflicting_paths_found": "",
"connected_users": "",
"contact_message_label": "",
+ "contact_sales": "",
"contact_us": "",
"continue_github_merge": "",
"copy": "",
@@ -141,6 +142,7 @@
"description": "",
"dictionary": "",
"did_you_know_institution_providing_professional": "",
+ "did_you_know_that_overleaf_offers": "",
"disable_stop_on_first_error": "",
"dismiss": "",
"dismiss_error_popup": "",
@@ -184,6 +186,7 @@
"email_or_password_wrong_try_again": "",
"emails_and_affiliations_explanation": "",
"emails_and_affiliations_title": "",
+ "empower_your_organization_to_work_in_overleaf": "",
"error": "",
"error_performing_request": "",
"example_project": "",
@@ -555,6 +558,7 @@
"revoke": "",
"revoke_invite": "",
"role": "",
+ "save_money_groups_companies_research_organizations_can_save_money": "",
"save_or_cancel-cancel": "",
"save_or_cancel-or": "",
"save_or_cancel-save": "",
diff --git a/services/web/frontend/js/features/project-list/components/notifications/groups-and-enterprise-banner.tsx b/services/web/frontend/js/features/project-list/components/notifications/groups-and-enterprise-banner.tsx
new file mode 100644
index 0000000000..c8934175a1
--- /dev/null
+++ b/services/web/frontend/js/features/project-list/components/notifications/groups-and-enterprise-banner.tsx
@@ -0,0 +1,122 @@
+import { useCallback, useEffect, useMemo } from 'react'
+import Notification from './notification'
+import * as eventTracking from '../../../../infrastructure/event-tracking'
+import getMeta from '../../../../utils/meta'
+import customLocalStorage from '../../../../infrastructure/local-storage'
+import { useProjectListContext } from '../../context/project-list-context'
+import { Trans, useTranslation } from 'react-i18next'
+
+type GroupsAndEnterpriseBannerVariant =
+ | 'default'
+ | 'empower'
+ | 'save'
+ | 'did-you-know'
+
+export default function GroupsAndEnterpriseBanner() {
+ const { t } = useTranslation()
+ const { totalProjectsCount } = useProjectListContext()
+ const showGroupsAndEnterpriseBanner = getMeta(
+ 'ol-showGroupsAndEnterpriseBanner'
+ ) as boolean
+ const groupsAndEnterpriseBannerVariant = getMeta(
+ 'ol-groupsAndEnterpriseBannerVariant'
+ ) as GroupsAndEnterpriseBannerVariant
+
+ const eventTrackingSegmentation = useMemo(
+ () => ({
+ location: 'dashboard-banner-react',
+ variant: groupsAndEnterpriseBannerVariant,
+ page: '/project',
+ }),
+ [groupsAndEnterpriseBannerVariant]
+ )
+
+ const hasDismissedGroupsAndEnterpriseBanner = customLocalStorage.getItem(
+ 'has_dismissed_groups_and_enterprise_banner'
+ )
+
+ const handleClose = useCallback(() => {
+ customLocalStorage.setItem(
+ 'has_dismissed_groups_and_enterprise_banner',
+ true
+ )
+ }, [])
+
+ const handleClickContact = useCallback(() => {
+ eventTracking.sendMB(
+ 'groups-and-enterprise-banner-click',
+ eventTrackingSegmentation
+ )
+ }, [eventTrackingSegmentation])
+
+ useEffect(() => {
+ eventTracking.sendMB(
+ 'groups-and-enterprise-banner-prompt',
+ eventTrackingSegmentation
+ )
+ }, [eventTrackingSegmentation])
+
+ if (
+ totalProjectsCount === 0 ||
+ hasDismissedGroupsAndEnterpriseBanner ||
+ !showGroupsAndEnterpriseBanner
+ ) {
+ return null
+ }
+
+ // `getText` function has no default switch case since the whole notification
+ // should not be rendered if the `groupsAndEnterpriseBannerVariant` is not valid
+ if (!isVariantValid(groupsAndEnterpriseBannerVariant)) {
+ return null
+ }
+
+ // this shouldn't ever happens since the value of `showGroupsAndEnterpriseBanner` should be false
+ // if `groupsAndEnterpriseBannerVariant` is 'default'
+ // but just adding this check as an extra measure
+ if (groupsAndEnterpriseBannerVariant === 'default') {
+ return null
+ }
+
+ return (
+
+
+ {getText(groupsAndEnterpriseBannerVariant)}
+
+
+
+ {t('contact_sales')}
+
+
+
+ )
+}
+
+function isVariantValid(variant: GroupsAndEnterpriseBannerVariant) {
+ return (
+ variant === 'empower' || variant === 'save' || variant === 'did-you-know'
+ )
+}
+
+function getText(variant: GroupsAndEnterpriseBannerVariant) {
+ switch (variant) {
+ case 'empower':
+ return
+ case 'save':
+ return (
+ ]
+ }
+ />
+ )
+ case 'did-you-know':
+ return
+ }
+}
diff --git a/services/web/frontend/js/features/project-list/components/notifications/user-notifications.tsx b/services/web/frontend/js/features/project-list/components/notifications/user-notifications.tsx
index 61fd54809e..948e9c0613 100644
--- a/services/web/frontend/js/features/project-list/components/notifications/user-notifications.tsx
+++ b/services/web/frontend/js/features/project-list/components/notifications/user-notifications.tsx
@@ -2,6 +2,7 @@ import Common from './groups/common'
import Institution from './groups/institution'
import ConfirmEmail from './groups/confirm-email'
import ReconfirmationInfo from './groups/affiliation/reconfirmation-info'
+import GroupsAndEnterpriseBanner from './groups-and-enterprise-banner'
function UserNotifications() {
return (
@@ -11,6 +12,7 @@ function UserNotifications() {
+
)
diff --git a/services/web/frontend/js/main/project-list/notifications-controller.js b/services/web/frontend/js/main/project-list/notifications-controller.js
index 9a34fff5ee..31823fad5f 100644
--- a/services/web/frontend/js/main/project-list/notifications-controller.js
+++ b/services/web/frontend/js/main/project-list/notifications-controller.js
@@ -1,4 +1,5 @@
import App from '../../base'
+import getMeta from '../../utils/meta'
const ExposedSettings = window.ExposedSettings
App.controller('NotificationsController', function ($scope, $http) {
@@ -23,6 +24,29 @@ App.controller('NotificationsController', function ($scope, $http) {
}
})
+App.controller(
+ 'GroupsAndEnterpriseBannerController',
+ function ($scope, localStorage) {
+ $scope.hasDismissedGroupsAndEnterpriseBanner = localStorage(
+ 'has_dismissed_groups_and_enterprise_banner'
+ )
+
+ $scope.dismiss = () => {
+ localStorage('has_dismissed_groups_and_enterprise_banner', true)
+ $scope.hasDismissedGroupsAndEnterpriseBanner = true
+ }
+
+ $scope.groupsAndEnterpriseBannerVariant = getMeta(
+ 'ol-groupsAndEnterpriseBannerVariant'
+ )
+
+ $scope.isVariantValid =
+ $scope.groupsAndEnterpriseBannerVariant === 'save' ||
+ $scope.groupsAndEnterpriseBannerVariant === 'empower' ||
+ $scope.groupsAndEnterpriseBannerVariant === 'did-you-know'
+ }
+)
+
App.controller('ProjectInviteNotificationController', function ($scope, $http) {
// Shortcuts for translation keys
$scope.projectName = $scope.notification.messageOpts.projectName
diff --git a/services/web/locales/en.json b/services/web/locales/en.json
index 951caa58e1..bf831c0fdb 100644
--- a/services/web/locales/en.json
+++ b/services/web/locales/en.json
@@ -1940,5 +1940,9 @@
"try_out_one_of_our_plans_instead": "Try out one of our plans instead",
"browse_plans": "Browse plans",
"i_confirm_that_i_am_a_student": "I confirm that I am a student",
- "a_more_comprehensive_list_of_keyboard_shortcuts": "A more comprehensive list of keyboard shortcuts can be found in <0>this __appName__ project template0>"
+ "a_more_comprehensive_list_of_keyboard_shortcuts": "A more comprehensive list of keyboard shortcuts can be found in <0>this __appName__ project template0>",
+ "contact_sales": "Contact Sales",
+ "empower_your_organization_to_work_in_overleaf": "Empower your organization to work in __appName__! Get a group or organizational plan.",
+ "save_money_groups_companies_research_organizations_can_save_money": "<0>Save Money0>! Groups, Companies and Research Organizations can save money with our Group and Enterprise plans — request information or a quote.",
+ "did_you_know_that_overleaf_offers": "Did you know that __appName__ offers group and organization-wide subscription options? Request information or a quote."
}
diff --git a/services/web/test/frontend/features/project-list/components/notifications.test.tsx b/services/web/test/frontend/features/project-list/components/notifications.test.tsx
index a4a2adc352..263ea048a6 100644
--- a/services/web/test/frontend/features/project-list/components/notifications.test.tsx
+++ b/services/web/test/frontend/features/project-list/components/notifications.test.tsx
@@ -27,6 +27,8 @@ import {
} from '../../../../../types/project/dashboard/notification'
import { DeepPartial } from '../../../../../types/utils'
import { Project } from '../../../../../types/project/dashboard/api'
+import GroupsAndEnterpriseBanner from '../../../../../frontend/js/features/project-list/components/notifications/groups-and-enterprise-banner'
+import localStorage from '../../../../../frontend/js/infrastructure/local-storage'
const renderWithinProjectListProvider = (Component: React.ComponentType) => {
render(, {
@@ -640,4 +642,125 @@ describe('', function () {
expect(screen.queryByRole('alert')).to.be.null
})
})
+
+ describe('', function () {
+ beforeEach(function () {
+ window.metaAttributesCache = window.metaAttributesCache || new Map()
+ localStorage.clear()
+ fetchMock.reset()
+
+ // at least one project is required to show some notifications
+ const projects = [{}] as Project[]
+ fetchMock.post(/\/api\/project/, {
+ status: 200,
+ body: {
+ projects,
+ totalSize: projects.length,
+ },
+ })
+ })
+
+ afterEach(function () {
+ fetchMock.reset()
+ window.metaAttributesCache = window.metaAttributesCache || new Map()
+ })
+
+ it('does not show the banner for users that are in group or are affiliated or assigned in the `default` variant', async function () {
+ window.metaAttributesCache.set('ol-showGroupsAndEnterpriseBanner', false)
+
+ renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
+ await fetchMock.flush(true)
+
+ expect(screen.queryByRole('link', { name: 'Contact Sales' })).to.be.null
+ })
+
+ it('does not show the banner for users that have already dismissed it', async function () {
+ window.metaAttributesCache.set('ol-showGroupsAndEnterpriseBanner', true)
+ localStorage.setItem('has_dismissed_groups_and_enterprise_banner', true)
+
+ renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
+ await fetchMock.flush(true)
+
+ expect(screen.queryByRole('link', { name: 'Contact Sales' })).to.be.null
+ })
+
+ describe('users that are not in group and are not affiliated', function () {
+ beforeEach(function () {
+ localStorage.clear()
+ fetchMock.reset()
+
+ // at least one project is required to show some notifications
+ const projects = [{}] as Project[]
+ fetchMock.post(/\/api\/project/, {
+ status: 200,
+ body: {
+ projects,
+ totalSize: projects.length,
+ },
+ })
+
+ window.metaAttributesCache.set('ol-showGroupsAndEnterpriseBanner', true)
+ })
+
+ afterEach(function () {
+ fetchMock.reset()
+ window.metaAttributesCache = window.metaAttributesCache || new Map()
+ })
+
+ after(function () {
+ localStorage.clear()
+ })
+
+ it('will show the correct text for the `save` split test variant', async function () {
+ window.metaAttributesCache.set(
+ 'ol-groupsAndEnterpriseBannerVariant',
+ 'save'
+ )
+
+ renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
+ await fetchMock.flush(true)
+
+ screen.getByText(
+ /Groups, Companies and Research Organizations can save money with our Group and Enterprise plans — request information or a quote./
+ )
+ const link = screen.getByRole('link', { name: 'Contact Sales' })
+
+ expect(link.getAttribute('href')).to.equal(`/for/contact-sales`)
+ })
+
+ it('will show the correct text for the `did-you-know` split test variant', async function () {
+ window.metaAttributesCache.set(
+ 'ol-groupsAndEnterpriseBannerVariant',
+ 'did-you-know'
+ )
+
+ renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
+ await fetchMock.flush(true)
+
+ screen.getByText(
+ 'Did you know that Overleaf offers group and organization-wide subscription options? Request information or a quote.'
+ )
+ const link = screen.getByRole('link', { name: 'Contact Sales' })
+
+ expect(link.getAttribute('href')).to.equal(`/for/contact-sales`)
+ })
+
+ it('will show the correct text for the `empower` split test variant', async function () {
+ window.metaAttributesCache.set(
+ 'ol-groupsAndEnterpriseBannerVariant',
+ 'empower'
+ )
+
+ renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
+ await fetchMock.flush(true)
+
+ screen.getByText(
+ 'Empower your organization to work in Overleaf! Get a group or organizational plan.'
+ )
+ const link = screen.getByRole('link', { name: 'Contact Sales' })
+
+ expect(link.getAttribute('href')).to.equal(`/for/contact-sales`)
+ })
+ })
+ })
})