From 16b2d27fde7b260272a1b66bd85a52a45c58a1e9 Mon Sep 17 00:00:00 2001 From: Thomas Date: Mon, 27 Feb 2023 16:49:19 +0100 Subject: [PATCH] Update B2B Groups and Enterprise banners (#11989) * Tear down B2B banner ad split test and implement updated ads GitOrigin-RevId: 7d09d54bef7cb4e2b2b597d3834e0f58551b179e --- .../src/Features/Project/ProjectController.js | 30 +--- .../Features/Project/ProjectListController.js | 29 +--- .../app/views/project/list/notifications.pug | 7 +- .../web/frontend/extracted-translations.json | 2 - .../groups-and-enterprise-banner.tsx | 128 +++++++++--------- .../project-list/notifications-controller.js | 13 +- services/web/locales/en.json | 2 - .../components/notifications.test.tsx | 104 ++++++++++---- 8 files changed, 164 insertions(+), 151 deletions(-) diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index 1d0563082f..32316bee3e 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/services/web/app/src/Features/Project/ProjectController.js @@ -532,26 +532,6 @@ const ProjectController = { } ) }, - 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) - } - } - ) - }, survey(cb) { SurveyHandler.getSurvey(userId, (err, survey) => { if (err) { @@ -573,7 +553,6 @@ const ProjectController = { notifications, user, userEmailsData, - groupsAndEnterpriseBannerAssignment, userIsMemberOfGroupSubscription, } = results @@ -707,13 +686,15 @@ const ProjectController = { affiliation => affiliation.licence && affiliation.licence !== 'free' ) - // groupsAndEnterpriseBannerAssignment.variant = 'default' | 'empower' | 'save' | 'did-you-know' const showGroupsAndEnterpriseBanner = - groupsAndEnterpriseBannerAssignment.variant !== 'default' && Features.hasFeature('saas') && !userIsMemberOfGroupSubscription && !hasPaidAffiliation + const groupsAndEnterpriseBannerVariant = + showGroupsAndEnterpriseBanner && + _.sample(['did-you-know', 'on-premise', 'people', 'FOMO']) + ProjectController._injectProjectUsers(projects, (error, projects) => { if (error != null) { return next(error) @@ -739,8 +720,7 @@ const ProjectController = { usersBestSubscription: results.usersBestSubscription, survey: results.survey, showGroupsAndEnterpriseBanner, - groupsAndEnterpriseBannerVariant: - groupsAndEnterpriseBannerAssignment.variant, + groupsAndEnterpriseBannerVariant, } const paidUser = diff --git a/services/web/app/src/Features/Project/ProjectListController.js b/services/web/app/src/Features/Project/ProjectListController.js index f69d23472f..170b87b530 100644 --- a/services/web/app/src/Features/Project/ProjectListController.js +++ b/services/web/app/src/Features/Project/ProjectListController.js @@ -17,7 +17,6 @@ const NotificationsHandler = require('../Notifications/NotificationsHandler') const Modules = require('../../infrastructure/Modules') const { OError, V1ConnectionError } = require('../Errors/Errors') 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') @@ -270,25 +269,7 @@ 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) @@ -306,12 +287,15 @@ async function projectListReactPage(req, res, next) { affiliation => affiliation.licence && affiliation.licence !== 'free' ) - showGroupsAndEnterpriseBanner = - (groupsAndEnterpriseBannerAssignment?.variant ?? 'default') !== 'default' && + const showGroupsAndEnterpriseBanner = Features.hasFeature('saas') && !userIsMemberOfGroupSubscription && !hasPaidAffiliation + const groupsAndEnterpriseBannerVariant = + showGroupsAndEnterpriseBanner && + _.sample(['did-you-know', 'on-premise', 'people', 'FOMO']) + res.render('project/list-react', { title: 'your_projects', usersBestSubscription, @@ -327,8 +311,7 @@ async function projectListReactPage(req, res, next) { portalTemplates, prefetchedProjectsBlob, showGroupsAndEnterpriseBanner, - groupsAndEnterpriseBannerVariant: - groupsAndEnterpriseBannerAssignment?.variant ?? 'default', + groupsAndEnterpriseBannerVariant, projectDashboardReact: true, // used in navbar }) } diff --git a/services/web/app/views/project/list/notifications.pug b/services/web/app/views/project/list/notifications.pug index d7cba28192..b5cef5fda2 100644 --- a/services/web/app/views/project/list/notifications.pug +++ b/services/web/app/views/project/list/notifications.pug @@ -267,12 +267,13 @@ include ../../_mixins/reconfirm_affiliation ) .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")} + span(ng-switch-when="on-premise") Overleaf On-Premises: Does your company want to keep its data within its firewall? Overleaf offers Server Pro, an on-premises solution for companies. Get in touch to learn more. + span(ng-switch-when="people") Other people at your company may already be using Overleaf. Save money with Overleaf group and company-wide subscriptions. Request more information. + span(ng-switch-when="FOMO") Why do Fortune 500 companies and top research institutions trust Overleaf to streamline their collaboration? Get in touch to learn more. .notification-action a.pull-right.btn.btn-sm.btn-info( - href="/for/contact-sales" + href="/for/contact-sales{{urlVariantSuffix}}" target="_blank" event-tracking="groups-and-enterprise-banner-click" event-tracking-mb="true" diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index ec1ecd669f..827288e568 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -227,7 +227,6 @@ "email_or_password_wrong_try_again": "", "emails_and_affiliations_explanation": "", "emails_and_affiliations_title": "", - "empower_your_organization_to_work_in_overleaf": "", "end_of_document": "", "error": "", "error_performing_request": "", @@ -670,7 +669,6 @@ "revoke_invite": "", "rich_text_is_only_available_for_tex_files": "", "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 index c8934175a1..fbf0bb3356 100644 --- 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 @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo } from 'react' +import { useCallback, useEffect } from 'react' import Notification from './notification' import * as eventTracking from '../../../../infrastructure/event-tracking' import getMeta from '../../../../utils/meta' @@ -6,74 +6,58 @@ 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' +const variants = ['did-you-know', 'on-premise', 'people', 'FOMO'] as const +type GroupsAndEnterpriseBannerVariant = typeof variants[number] + +let viewEventSent = false export default function GroupsAndEnterpriseBanner() { const { t } = useTranslation() const { totalProjectsCount } = useProjectListContext() - const showGroupsAndEnterpriseBanner = getMeta( + + const showGroupsAndEnterpriseBanner: boolean = 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 groupsAndEnterpriseBannerVariant: GroupsAndEnterpriseBannerVariant = + getMeta('ol-groupsAndEnterpriseBannerVariant') - const hasDismissedGroupsAndEnterpriseBanner = customLocalStorage.getItem( - 'has_dismissed_groups_and_enterprise_banner' - ) + const hasDismissedGroupsAndEnterpriseBanner = hasRecentlyDismissedBanner() + + const contactSalesUrl = `/for/contact-sales-${ + variants.indexOf(groupsAndEnterpriseBannerVariant) + 1 + }` + + const shouldRenderBanner = + showGroupsAndEnterpriseBanner && + totalProjectsCount !== 0 && + !hasDismissedGroupsAndEnterpriseBanner && + isVariantValid(groupsAndEnterpriseBannerVariant) const handleClose = useCallback(() => { customLocalStorage.setItem( 'has_dismissed_groups_and_enterprise_banner', - true + new Date() ) }, []) const handleClickContact = useCallback(() => { - eventTracking.sendMB( - 'groups-and-enterprise-banner-click', - eventTrackingSegmentation - ) - }, [eventTrackingSegmentation]) + eventTracking.sendMB('groups-and-enterprise-banner-click', { + location: 'dashboard-banner-react', + variant: groupsAndEnterpriseBannerVariant, + }) + }, [groupsAndEnterpriseBannerVariant]) useEffect(() => { - eventTracking.sendMB( - 'groups-and-enterprise-banner-prompt', - eventTrackingSegmentation - ) - }, [eventTrackingSegmentation]) + if (!viewEventSent && shouldRenderBanner) { + eventTracking.sendMB('groups-and-enterprise-banner-prompt', { + location: 'dashboard-banner-react', + variant: groupsAndEnterpriseBannerVariant, + }) + viewEventSent = true + } + }, [shouldRenderBanner, groupsAndEnterpriseBannerVariant]) - 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') { + if (!shouldRenderBanner) { return null } @@ -85,8 +69,9 @@ export default function GroupsAndEnterpriseBanner() { {t('contact_sales')} @@ -97,26 +82,35 @@ export default function GroupsAndEnterpriseBanner() { } function isVariantValid(variant: GroupsAndEnterpriseBannerVariant) { - return ( - variant === 'empower' || variant === 'save' || variant === 'did-you-know' - ) + return variants.includes(variant) } function getText(variant: GroupsAndEnterpriseBannerVariant) { switch (variant) { - case 'empower': - return - case 'save': - return ( - ] - } - /> - ) case 'did-you-know': return + case 'on-premise': + return 'Overleaf On-Premises: Does your company want to keep its data within its firewall? Overleaf offers Server Pro, an on-premises solution for companies. Get in touch to learn more.' + case 'people': + return 'Other people at your company may already be using Overleaf. Save money with Overleaf group and company-wide subscriptions. Request more information.' + case 'FOMO': + return 'Why do Fortune 500 companies and top research institutions trust Overleaf to streamline their collaboration? Get in touch to learn more.' } } + +function hasRecentlyDismissedBanner() { + const dismissed = customLocalStorage.getItem( + 'has_dismissed_groups_and_enterprise_banner' + ) + // previous banner set this to 'true', which shouldn't hide the new banner + if (!dismissed || dismissed === 'true') { + return false + } + + const dismissedDate = new Date(dismissed) + const recentlyDismissedCutoff = new Date() + recentlyDismissedCutoff.setDate(recentlyDismissedCutoff.getDate() - 30) // 30 days + + // once the dismissedDate passes the cut off mark, banner will be shown again + return dismissedDate > recentlyDismissedCutoff +} 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 31823fad5f..015537e62a 100644 --- a/services/web/frontend/js/main/project-list/notifications-controller.js +++ b/services/web/frontend/js/main/project-list/notifications-controller.js @@ -40,10 +40,15 @@ App.controller( 'ol-groupsAndEnterpriseBannerVariant' ) - $scope.isVariantValid = - $scope.groupsAndEnterpriseBannerVariant === 'save' || - $scope.groupsAndEnterpriseBannerVariant === 'empower' || - $scope.groupsAndEnterpriseBannerVariant === 'did-you-know' + const valid = ['did-you-know', 'on-premise', 'people', 'FOMO'] + + $scope.isVariantValid = valid.includes( + $scope.groupsAndEnterpriseBannerVariant + ) + + $scope.urlVariantSuffix = $scope.isVariantValid + ? `-${valid.indexOf($scope.groupsAndEnterpriseBannerVariant) + 1}` + : '' } ) diff --git a/services/web/locales/en.json b/services/web/locales/en.json index ca9fc1480c..dbb34d4373 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -420,7 +420,6 @@ "emails": "Emails", "emails_and_affiliations_explanation": "Add additional email addresses to your account to access any upgrades your university or institution has, to make it easier for collaborators to find you, and to make sure you can recover your account.", "emails_and_affiliations_title": "Emails and Affiliations", - "empower_your_organization_to_work_in_overleaf": "Empower your organization to work in __appName__! Get a group or organizational plan.", "empty_zip_file": "Zip doesn’t contain any file", "en": "English", "end_of_document": "End of document", @@ -1273,7 +1272,6 @@ "saml": "SAML", "saml_create_admin_instructions": "Choose an email address for the first __appName__ admin account. This should correspond to an account in the SAML system. You will then be asked to log in with this account.", "save_20_percent_by_paying_annually": "Save 20% by paying annually", - "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.", "save_or_cancel-cancel": "Cancel", "save_or_cancel-or": "or", "save_or_cancel-save": "Save", 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 263ea048a6..63afd562b9 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 @@ -658,6 +658,11 @@ describe('', function () { totalSize: projects.length, }, }) + + window.metaAttributesCache.set( + 'ol-groupsAndEnterpriseBannerVariant', + 'did-you-know' + ) }) afterEach(function () { @@ -665,7 +670,7 @@ describe('', function () { 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 () { + it('does not show the banner for users that are in group or are affiliated', async function () { window.metaAttributesCache.set('ol-showGroupsAndEnterpriseBanner', false) renderWithinProjectListProvider(GroupsAndEnterpriseBanner) @@ -674,13 +679,45 @@ describe('', function () { 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 () { + it('shows the banner for users that have dismissed the previous banners', 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.not.be + .null + }) + + it('shows the banner for users that have dismissed the banner more than 30 days ago', async function () { + const dismissed = new Date() + dismissed.setDate(dismissed.getDate() - 31) // 31 days + window.metaAttributesCache.set('ol-showGroupsAndEnterpriseBanner', true) + localStorage.setItem( + 'has_dismissed_groups_and_enterprise_banner', + dismissed + ) + + renderWithinProjectListProvider(GroupsAndEnterpriseBanner) + await fetchMock.flush(true) + + expect(screen.queryByRole('link', { name: 'Contact Sales' })).to.not.be + .null + }) + + it('does not show the banner for users that have dismissed the banner within the last 30 days', async function () { + const dismissed = new Date() + dismissed.setDate(dismissed.getDate() - 29) // 29 days + window.metaAttributesCache.set('ol-showGroupsAndEnterpriseBanner', true) + localStorage.setItem( + 'has_dismissed_groups_and_enterprise_banner', + dismissed + ) + + renderWithinProjectListProvider(GroupsAndEnterpriseBanner) + await fetchMock.flush(true) + expect(screen.queryByRole('link', { name: 'Contact Sales' })).to.be.null }) @@ -711,24 +748,7 @@ describe('', 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 () { + it('will show the correct text for the `did-you-know` variant', async function () { window.metaAttributesCache.set( 'ol-groupsAndEnterpriseBannerVariant', 'did-you-know' @@ -742,24 +762,58 @@ describe('', function () { ) const link = screen.getByRole('link', { name: 'Contact Sales' }) - expect(link.getAttribute('href')).to.equal(`/for/contact-sales`) + expect(link.getAttribute('href')).to.equal(`/for/contact-sales-1`) }) - it('will show the correct text for the `empower` split test variant', async function () { + it('will show the correct text for the `on-premise` variant', async function () { window.metaAttributesCache.set( 'ol-groupsAndEnterpriseBannerVariant', - 'empower' + 'on-premise' ) renderWithinProjectListProvider(GroupsAndEnterpriseBanner) await fetchMock.flush(true) screen.getByText( - 'Empower your organization to work in Overleaf! Get a group or organizational plan.' + 'Overleaf On-Premises: Does your company want to keep its data within its firewall? Overleaf offers Server Pro, an on-premises solution for companies. Get in touch to learn more.' ) const link = screen.getByRole('link', { name: 'Contact Sales' }) - expect(link.getAttribute('href')).to.equal(`/for/contact-sales`) + expect(link.getAttribute('href')).to.equal(`/for/contact-sales-2`) + }) + + it('will show the correct text for the `people` variant', async function () { + window.metaAttributesCache.set( + 'ol-groupsAndEnterpriseBannerVariant', + 'people' + ) + + renderWithinProjectListProvider(GroupsAndEnterpriseBanner) + await fetchMock.flush(true) + + screen.getByText( + 'Other people at your company may already be using Overleaf. Save money with Overleaf group and company-wide subscriptions. Request more information.' + ) + const link = screen.getByRole('link', { name: 'Contact Sales' }) + + expect(link.getAttribute('href')).to.equal(`/for/contact-sales-3`) + }) + + it('will show the correct text for the `FOMO` variant', async function () { + window.metaAttributesCache.set( + 'ol-groupsAndEnterpriseBannerVariant', + 'FOMO' + ) + + renderWithinProjectListProvider(GroupsAndEnterpriseBanner) + await fetchMock.flush(true) + + screen.getByText( + 'Why do Fortune 500 companies and top research institutions trust Overleaf to streamline their collaboration? Get in touch to learn more.' + ) + const link = screen.getByRole('link', { name: 'Contact Sales' }) + + expect(link.getAttribute('href')).to.equal(`/for/contact-sales-4`) }) }) })