Просмотр исходного кода

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
M Fahru 3 лет назад
Родитель
Сommit
bed2596468

+ 57 - 2
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 =

+ 46 - 0
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',
   })
 }
 

+ 2 - 0
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

+ 1 - 0
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
 

+ 32 - 0
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")}

+ 4 - 0
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": "",

+ 122 - 0
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 (
+    <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" />
+  }
+}

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

+ 24 - 0
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

+ 5 - 1
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</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."
 }

+ 123 - 0
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(<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`)
+      })
+    })
+  })
 })