Implement groups & enterprise awareness banner on project dashboard (#10818)

- Implement the banner on both react and non-react project dashboard
- Use split test with 4 different variants, `save`, `empower`, `did-you-know`, and `default`, each variant has a different copy, except the `default` which won't show the banner to users

GitOrigin-RevId: ee76769dfd38b8e52de8cc0f201c24e41905d016
This commit is contained in:
M Fahru 2022-12-13 01:56:32 -07:00 committed by Copybot
parent 55375c78bf
commit bed2596468
11 changed files with 418 additions and 3 deletions

View file

@ -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 =

View file

@ -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',
})
}

View file

@ -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

View file

@ -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

View file

@ -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")}

View file

@ -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": "",

View file

@ -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 (
<Notification bsStyle="info" onDismiss={handleClose}>
<Notification.Body>
<span>{getText(groupsAndEnterpriseBannerVariant)}</span>
</Notification.Body>
<Notification.Action>
<a
className="pull-right btn btn-info btn-sm"
href="/for/contact-sales"
target="_blank"
onClick={handleClickContact}
>
{t('contact_sales')}
</a>
</Notification.Action>
</Notification>
)
}
function isVariantValid(variant: GroupsAndEnterpriseBannerVariant) {
return (
variant === 'empower' || variant === 'save' || variant === 'did-you-know'
)
}
function getText(variant: GroupsAndEnterpriseBannerVariant) {
switch (variant) {
case 'empower':
return <Trans i18nKey="empower_your_organization_to_work_in_overleaf" />
case 'save':
return (
<Trans
i18nKey="save_money_groups_companies_research_organizations_can_save_money"
components={
/* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
[<strong />]
}
/>
)
case 'did-you-know':
return <Trans i18nKey="did_you_know_that_overleaf_offers" />
}
}

View file

@ -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() {
<Institution />
<ConfirmEmail />
<ReconfirmationInfo />
<GroupsAndEnterpriseBanner />
</ul>
</div>
)

View file

@ -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

View file

@ -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 template</0>"
"a_more_comprehensive_list_of_keyboard_shortcuts": "A more comprehensive list of keyboard shortcuts can be found in <0>this __appName__ project template</0>",
"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 Money</0>! 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."
}

View file

@ -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(<Component />, {
@ -640,4 +642,125 @@ describe('<UserNotifications />', function () {
expect(screen.queryByRole('alert')).to.be.null
})
})
describe('<GroupsAndEnterpriseBanner />', 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`)
})
})
})
})