From bed2596468d204fb124576c7fe6d69bea528724b Mon Sep 17 00:00:00 2001 From: M Fahru Date: Tue, 13 Dec 2022 01:56:32 -0700 Subject: [PATCH] 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 --- .../src/Features/Project/ProjectController.js | 59 ++++++++- .../Features/Project/ProjectListController.js | 46 +++++++ services/web/app/views/project/list-react.pug | 2 + services/web/app/views/project/list.pug | 1 + .../app/views/project/list/notifications.pug | 32 +++++ .../web/frontend/extracted-translations.json | 4 + .../groups-and-enterprise-banner.tsx | 122 +++++++++++++++++ .../notifications/user-notifications.tsx | 2 + .../project-list/notifications-controller.js | 24 ++++ services/web/locales/en.json | 6 +- .../components/notifications.test.tsx | 123 ++++++++++++++++++ 11 files changed, 418 insertions(+), 3 deletions(-) create mode 100644 services/web/frontend/js/features/project-list/components/notifications/groups-and-enterprise-banner.tsx 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 template" + "a_more_comprehensive_list_of_keyboard_shortcuts": "A more comprehensive list of keyboard shortcuts can be found in <0>this __appName__ project template", + "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! 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`) + }) + }) + }) })