diff --git a/services/web/app/src/Features/Project/ProjectListController.mjs b/services/web/app/src/Features/Project/ProjectListController.mjs
index 73f4d3dc5b..a56fde2d05 100644
--- a/services/web/app/src/Features/Project/ProjectListController.mjs
+++ b/services/web/app/src/Features/Project/ProjectListController.mjs
@@ -1,5 +1,6 @@
// @ts-check
import _ from 'lodash'
+import moment from 'moment'
import Metrics from '@overleaf/metrics'
import Settings from '@overleaf/settings'
@@ -414,11 +415,15 @@ async function projectListPage(req, res, next) {
const { showUSGovBanner, usGovBannerVariant } = usGovBanner
+ const isUser30DaysOld = moment.utc().diff(user.signUpDate, 'days') > 30
+
const showGroupsAndEnterpriseBanner =
Features.hasFeature('saas') &&
!showUSGovBanner &&
!userIsMemberOfGroupSubscription &&
- !hasPaidAffiliation
+ !hasPaidAffiliation &&
+ !inactiveTutorials.includes('groups-enterprise-banner-repeat') &&
+ isUser30DaysOld
const groupsAndEnterpriseBannerVariant =
showGroupsAndEnterpriseBanner &&
@@ -564,6 +569,7 @@ async function projectListPage(req, res, next) {
primaryOccupation,
role,
usedLatex,
+ inactiveTutorials,
})
}
diff --git a/services/web/app/src/Features/Tutorial/TutorialController.mjs b/services/web/app/src/Features/Tutorial/TutorialController.mjs
index df9183e094..3b5619bbb1 100644
--- a/services/web/app/src/Features/Tutorial/TutorialController.mjs
+++ b/services/web/app/src/Features/Tutorial/TutorialController.mjs
@@ -22,6 +22,8 @@ const VALID_KEYS = [
'ide-redesign-beta-intro',
'ide-redesign-labs-user-beta-promo',
'rolling-compile-image-changed',
+ 'groups-enterprise-banner',
+ 'groups-enterprise-banner-repeat',
]
async function completeTutorial(req, res, next) {
diff --git a/services/web/app/views/project/list-react.pug b/services/web/app/views/project/list-react.pug
index af239f3df4..da1ee05108 100644
--- a/services/web/app/views/project/list-react.pug
+++ b/services/web/app/views/project/list-react.pug
@@ -89,6 +89,7 @@ block append meta
data-type='string'
content=usGovBannerVariant
)
+ meta(name='ol-inactiveTutorials' data-type='json' content=inactiveTutorials)
block content
#project-list-root
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 6a10d86b9a..86e10f58cd 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
@@ -10,6 +10,9 @@ import {
GroupsAndEnterpriseBannerVariants,
} from '../../../../../../types/project/dashboard/notification'
import OLButton from '@/shared/components/ol/ol-button'
+import { postJSON } from '@/infrastructure/fetch-json'
+import moment from 'moment'
+import { debugConsole } from '@/utils/debugging'
type urlForVariantsType = {
[key in GroupsAndEnterpriseBannerVariant]: string // eslint-disable-line no-unused-vars
@@ -20,6 +23,9 @@ const urlForVariants: urlForVariantsType = {
FOMO: '/for/contact-sales-4',
}
+const INITIAL_TUTORIAL_KEY = 'groups-enterprise-banner'
+const REPEAT_TUTORIAL_KEY = 'groups-enterprise-banner-repeat'
+
let viewEventSent = false
export default function GroupsAndEnterpriseBanner() {
@@ -32,23 +38,35 @@ export default function GroupsAndEnterpriseBanner() {
const groupsAndEnterpriseBannerVariant = getMeta(
'ol-groupsAndEnterpriseBannerVariant'
)
+ const inactiveTutorials = getMeta('ol-inactiveTutorials')
- const hasDismissedGroupsAndEnterpriseBanner = hasRecentlyDismissedBanner()
-
+ const locallyDismissedBanner = hasLocallyDismissedBanner()
const contactSalesUrl = urlForVariants[groupsAndEnterpriseBannerVariant]
const shouldRenderBanner =
showGroupsAndEnterpriseBanner &&
totalProjectsCount !== 0 &&
- !hasDismissedGroupsAndEnterpriseBanner &&
+ !inactiveTutorials.includes(REPEAT_TUTORIAL_KEY) &&
+ !locallyDismissedBanner &&
isVariantValid(groupsAndEnterpriseBannerVariant)
- const handleClose = useCallback(() => {
- customLocalStorage.setItem(
- 'has_dismissed_groups_and_enterprise_banner',
- new Date()
- )
- }, [])
+ const handleClose = useCallback(async () => {
+ if (!inactiveTutorials.includes(INITIAL_TUTORIAL_KEY)) {
+ await postJSON(`/tutorial/${REPEAT_TUTORIAL_KEY}/postpone`, {
+ body: {
+ postponedUntil: moment().add(60, 'days').toISOString(),
+ },
+ }).catch(debugConsole.error)
+
+ await postJSON(`/tutorial/${INITIAL_TUTORIAL_KEY}/complete`).catch(
+ debugConsole.error
+ )
+ } else {
+ await postJSON(`/tutorial/${REPEAT_TUTORIAL_KEY}/complete`).catch(
+ debugConsole.error
+ )
+ }
+ }, [inactiveTutorials])
const handleClickContact = useCallback(() => {
eventTracking.sendMB('groups-and-enterprise-banner-click', {
@@ -67,6 +85,16 @@ export default function GroupsAndEnterpriseBanner() {
}
}, [shouldRenderBanner, groupsAndEnterpriseBannerVariant])
+ useEffect(() => {
+ // Persist local dismissal status from previous banner versions to the backend
+ if (
+ locallyDismissedBanner &&
+ !inactiveTutorials.includes(REPEAT_TUTORIAL_KEY)
+ ) {
+ handleClose()
+ }
+ }, [handleClose, locallyDismissedBanner, inactiveTutorials])
+
if (!shouldRenderBanner) {
return null
}
@@ -120,7 +148,7 @@ function BannerContent({
}
}
-function hasRecentlyDismissedBanner() {
+function hasLocallyDismissedBanner() {
const dismissed = customLocalStorage.getItem(
'has_dismissed_groups_and_enterprise_banner'
)
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 dd75920b5b..1126fa432d 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
@@ -949,6 +949,8 @@ describe('', function () {
'ol-groupsAndEnterpriseBannerVariant',
'on-premise'
)
+
+ window.metaAttributesCache.set('ol-inactiveTutorials', '[]')
})
afterEach(function () {
@@ -974,9 +976,9 @@ describe('', function () {
await screen.findByRole('link', { name: 'Contact sales' })
})
- it('shows the banner for users that have dismissed the banner more than 30 days ago', async function () {
+ it('does not show the banner for users that have dismissed the banner within the last 30 days and before server-side state', async function () {
const dismissed = new Date()
- dismissed.setDate(dismissed.getDate() - 31) // 31 days
+ dismissed.setDate(dismissed.getDate() - 29) // 29 days
window.metaAttributesCache.set('ol-showGroupsAndEnterpriseBanner', true)
localStorage.setItem(
'has_dismissed_groups_and_enterprise_banner',
@@ -986,17 +988,28 @@ describe('', function () {
renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
await fetchMock.callHistory.flush(true)
- await screen.findByRole('link', { name: 'Contact sales' })
+ expect(screen.queryByRole('link', { name: 'Contact sales' })).to.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
+ it('shows the banner for users who have not dismissed the repeat appearance', async function () {
+ window.metaAttributesCache.set(
+ 'ol-inactiveTutorials',
+ '["groups-enterprise-banner"]'
)
+ window.metaAttributesCache.set('ol-showGroupsAndEnterpriseBanner', true)
+
+ renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
+ await fetchMock.callHistory.flush(true)
+
+ expect(screen.queryByRole('link', { name: 'Contact sales' })).to.be.null
+ })
+
+ it('does not show the banner for users with both inactive tutorials', async function () {
+ window.metaAttributesCache.set(
+ 'ol-inactiveTutorials',
+ '["groups-enterprise-banner", "groups-enterprise-banner-repeat"]'
+ )
+ window.metaAttributesCache.set('ol-showGroupsAndEnterpriseBanner', true)
renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
await fetchMock.callHistory.flush(true)