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)