diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index 47cc292062..f9dd6358e8 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/services/web/app/src/Features/Project/ProjectController.js @@ -38,6 +38,7 @@ const UserController = require('../User/UserController') const AnalyticsManager = require('../Analytics/AnalyticsManager') const Modules = require('../../infrastructure/Modules') const SplitTestHandler = require('../SplitTests/SplitTestHandler') +const SplitTestV2Handler = require('../SplitTests/SplitTestV2Handler') const { getNewLogsUIVariantForUser } = require('../Helpers/NewLogsUI') const _ssoAvailable = (affiliation, session, linkedInstitutionIds) => { @@ -725,23 +726,36 @@ const ProjectController = { } ) }, + sharingModalSplitTest(cb) { + SplitTestV2Handler.assignInLocalsContext( + res, + userId, + 'project-share-modal-paywall', + err => { + cb(err, null) + } + ) + }, }, - (err, results) => { + ( + err, + { + project, + user, + subscription, + isTokenMember, + brandVariation, + pdfCachingFeatureFlag, + } + ) => { if (err != null) { OError.tag(err, 'error getting details for project page') return next(err) } - const { project } = results - const { user } = results - const { subscription } = results - const { brandVariation } = results - const { pdfCachingFeatureFlag } = results - const anonRequestToken = TokenAccessHandler.getRequestToken( req, projectId ) - const { isTokenMember } = results const allowedImageNames = ProjectHelper.getAllowedImagesForUser( sessionUser ) diff --git a/services/web/app/src/Features/SplitTests/SplitTestV2Handler.js b/services/web/app/src/Features/SplitTests/SplitTestV2Handler.js index 8526746e5a..6d32618809 100644 --- a/services/web/app/src/Features/SplitTests/SplitTestV2Handler.js +++ b/services/web/app/src/Features/SplitTests/SplitTestV2Handler.js @@ -81,6 +81,14 @@ async function getAssignment(userId, splitTestName, options) { } } +async function assignInLocalsContext(res, userId, splitTestName, options) { + const assignment = await getAssignment(userId, splitTestName, options) + if (!res.locals.splitTestVariants) { + res.locals.splitTestVariants = {} + } + res.locals.splitTestVariants[splitTestName] = assignment.variant +} + async function _getAssignmentMetadata(userId, splitTest) { const currentVersion = splitTest.getCurrentVersion() const phase = currentVersion.phase @@ -171,7 +179,9 @@ async function _getUser(id) { module.exports = { getAssignment: callbackify(getAssignment), + assignInLocalsContext: callbackify(assignInLocalsContext), promises: { getAssignment, + assignInLocalsContext, }, } diff --git a/services/web/app/views/layout.pug b/services/web/app/views/layout.pug index aa73207736..3b602f385b 100644 --- a/services/web/app/views/layout.pug +++ b/services/web/app/views/layout.pug @@ -85,6 +85,7 @@ html( }) //- Expose some settings globally to the frontend meta(name="ol-ExposedSettings" data-type="json" content=ExposedSettings) + meta(name="ol-splitTestVariants" data-type="json" content=splitTestVariants || {}) if (typeof(settings.algolia) != "undefined") meta(name="ol-algolia" data-type="json" content={ diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index b68ef99f1b..bc5208ce6e 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -199,6 +199,7 @@ "navigate_log_source": "", "navigation": "", "need_to_upgrade_for_more_collabs": "", + "need_to_upgrade_for_more_collabs_variant": "", "new_file": "", "new_folder": "", "new_name": "", diff --git a/services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade-content-default.js b/services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade-content-default.js new file mode 100644 index 0000000000..cc7727202b --- /dev/null +++ b/services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade-content-default.js @@ -0,0 +1,49 @@ +import Icon from '../../../shared/components/icon' +import { Trans, useTranslation } from 'react-i18next' + +export default function AddCollaboratorsUpgradeContentDefault() { + const { t } = useTranslation() + + return ( + <> +

+ . {t('also')}: +

+ + + ) +} diff --git a/services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade-content-variant.js b/services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade-content-variant.js new file mode 100644 index 0000000000..8e7293681e --- /dev/null +++ b/services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade-content-variant.js @@ -0,0 +1,56 @@ +import Icon from '../../../shared/components/icon' +import { Trans } from 'react-i18next' + +export default function AddCollaboratorsUpgradeContentVariant() { + return ( + <> +
+
+

+ +

+
+
+
+ + +
+
+ + ) +} diff --git a/services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade.js b/services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade.js index cd6a96808b..8c7623ffdc 100644 --- a/services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade.js +++ b/services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade.js @@ -1,63 +1,34 @@ import { useState } from 'react' -import { Trans, useTranslation } from 'react-i18next' +import { Trans } from 'react-i18next' import { Button } from 'react-bootstrap' import PropTypes from 'prop-types' import { useUserContext } from '../../../shared/context/user-context' -import Icon from '../../../shared/components/icon' import { upgradePlan } from '../../../main/account-upgrade' import StartFreeTrialButton from '../../../shared/components/start-free-trial-button' +import AddCollaboratorsUpgradeContentDefault from './add-collaborators-upgrade-content-default' +import AddCollaboratorsUpgradeContentVariant from './add-collaborators-upgrade-content-variant' +import { useSplitTestContext } from '../../../shared/context/split-test-context' export default function AddCollaboratorsUpgrade() { - const { t } = useTranslation() const user = useUserContext({ allowedFreeTrial: PropTypes.bool, }) const [startedFreeTrial, setStartedFreeTrial] = useState(false) + const { splitTestVariants } = useSplitTestContext({ + splitTestVariants: PropTypes.object, + }) + const variant = splitTestVariants['project-share-modal-paywall'] return ( -
-

- . {t('also')}: -

- - +
+ {!variant || variant === 'default' ? ( + + ) : ( + + )}

{user.allowedFreeTrial ? ( diff --git a/services/web/frontend/js/features/share-project-modal/components/link-sharing.js b/services/web/frontend/js/features/share-project-modal/components/link-sharing.js index c20fa81214..5f77f88051 100644 --- a/services/web/frontend/js/features/share-project-modal/components/link-sharing.js +++ b/services/web/frontend/js/features/share-project-modal/components/link-sharing.js @@ -7,6 +7,7 @@ import { useShareProjectContext } from './share-project-modal' import { setProjectAccessLevel } from '../utils/api' import CopyLink from '../../../shared/components/copy-link' import { useProjectContext } from '../../../shared/context/project-context' +import * as eventTracking from '../../../infrastructure/event-tracking' export default function LinkSharing() { const [inflight, setInflight] = useState(false) @@ -75,7 +76,10 @@ function PrivateSharing({ setAccessLevel, inflight }) { type="button" bsStyle="link" className="btn-inline-link" - onClick={() => setAccessLevel('tokenBased')} + onClick={() => { + setAccessLevel('tokenBased') + eventTracking.sendMB('link-sharing-click') + }} disabled={inflight} > diff --git a/services/web/frontend/js/features/share-project-modal/components/share-modal-body.js b/services/web/frontend/js/features/share-project-modal/components/share-modal-body.js index 4d0624c647..f4bd66df32 100644 --- a/services/web/frontend/js/features/share-project-modal/components/share-modal-body.js +++ b/services/web/frontend/js/features/share-project-modal/components/share-modal-body.js @@ -7,35 +7,116 @@ import ViewMember from './view-member' import OwnerInfo from './owner-info' import SendInvitesNotice from './send-invites-notice' import { useProjectContext } from '../../../shared/context/project-context' +import { useSplitTestContext } from '../../../shared/context/split-test-context' +import { Row } from 'react-bootstrap' +import PropTypes from 'prop-types' import RecaptchaConditions from '../../../shared/components/recaptcha-conditions' export default function ShareModalBody() { const { isAdmin } = useShareProjectContext() + const { splitTestVariants } = useSplitTestContext({ + splitTestVariants: PropTypes.object, + }) const project = useProjectContext() - return ( - <> - {isAdmin && } + switch (splitTestVariants['project-share-modal-paywall']) { + case 'new-copy-top': + return ( + <> + {isAdmin ? ( + <> + + + + ) : ( + + )} - + - {project.members.map(member => - isAdmin ? ( - - ) : ( - - ) - )} + {project.members.map(member => + isAdmin ? ( + + ) : ( + + ) + )} - {project.invites.map(invite => ( - - ))} + {project.invites.map(invite => ( + + ))} - {isAdmin ? : } - {!window.ExposedSettings.recaptchaDisabled?.invite && ( - - )} - - ) + {isAdmin && ( + <> +
+ + + )} + + {!window.ExposedSettings.recaptchaDisabled?.invite && ( + + )} + + ) + case 'new-copy-middle': + return ( + <> + + + {project.members.map(member => + isAdmin ? ( + + ) : ( + + ) + )} + + {project.invites.map(invite => ( + + ))} + + {isAdmin ? : } + + {isAdmin && ( + <> +
+ + + )} + + {!window.ExposedSettings.recaptchaDisabled?.invite && ( + + )} + + ) + case 'new-copy-bottom': + case 'default': + default: + return ( + <> + {isAdmin && } + + + + {project.members.map(member => + isAdmin ? ( + + ) : ( + + ) + )} + + {project.invites.map(invite => ( + + ))} + + {isAdmin ? : } + + {!window.ExposedSettings.recaptchaDisabled?.invite && ( + + )} + + ) + } } diff --git a/services/web/frontend/js/shared/context/root-context.js b/services/web/frontend/js/shared/context/root-context.js index d62ebd182c..906761bcbe 100644 --- a/services/web/frontend/js/shared/context/root-context.js +++ b/services/web/frontend/js/shared/context/root-context.js @@ -8,22 +8,25 @@ import { CompileProvider } from './compile-context' import { LayoutProvider } from './layout-context' import { ChatProvider } from '../../features/chat/context/chat-context' import { ProjectProvider } from './project-context' +import { SplitTestProvider } from './split-test-context' export function ContextRoot({ children, ide, settings }) { return ( - - - - - - - {children} - - - - - - + + + + + + + + {children} + + + + + + + ) } diff --git a/services/web/frontend/js/shared/context/split-test-context.js b/services/web/frontend/js/shared/context/split-test-context.js new file mode 100644 index 0000000000..ded5ca6577 --- /dev/null +++ b/services/web/frontend/js/shared/context/split-test-context.js @@ -0,0 +1,43 @@ +import { createContext, useContext, useMemo } from 'react' +import PropTypes from 'prop-types' +import getMeta from '../../utils/meta' + +export const SplitTestContext = createContext() + +SplitTestContext.Provider.propTypes = { + value: PropTypes.shape({ + splitTestVariants: PropTypes.object.isRequired, + }), +} + +export function SplitTestProvider({ children }) { + const value = useMemo( + () => ({ + splitTestVariants: getMeta('ol-splitTestVariants') || {}, + }), + [] + ) + + return ( + + {children} + + ) +} + +SplitTestProvider.propTypes = { + children: PropTypes.any, +} + +export function useSplitTestContext(propTypes) { + const context = useContext(SplitTestContext) + + PropTypes.checkPropTypes( + propTypes, + context, + 'data', + 'SplitTestContext.Provider' + ) + + return context +} diff --git a/services/web/locales/en.json b/services/web/locales/en.json index c19970b015..c9bf4a559a 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -981,6 +981,7 @@ "share_with_your_collabs": "Share with your collaborators", "share": "Share", "need_to_upgrade_for_more_collabs": "You need to upgrade your account to add more collaborators", + "need_to_upgrade_for_more_collabs_variant": "You have reached the maximum number of collaborators. Upgrade your account to add more.", "make_project_public": "Make project public", "make_project_public_consequences": "If you make your project public then anyone with the URL will be able to access it.", "allow_public_editing": "Allow public editing", diff --git a/services/web/test/frontend/helpers/render-with-context.js b/services/web/test/frontend/helpers/render-with-context.js index 2e9930be7e..a98ec7cb93 100644 --- a/services/web/test/frontend/helpers/render-with-context.js +++ b/services/web/test/frontend/helpers/render-with-context.js @@ -10,6 +10,7 @@ import { ChatProvider } from '../../../frontend/js/features/chat/context/chat-co import { IdeProvider } from '../../../frontend/js/shared/context/ide-context' import { get } from 'lodash' import { ProjectProvider } from '../../../frontend/js/shared/context/project-context' +import { SplitTestProvider } from '../../../frontend/js/shared/context/split-test-context' export function EditorProviders({ user = { id: '123abd' }, @@ -53,15 +54,17 @@ export function EditorProviders({ window._ide = { $scope, socket } return ( - - - - - {children} - - - - + + + + + + {children} + + + + + ) } diff --git a/services/web/test/unit/src/Project/ProjectControllerTests.js b/services/web/test/unit/src/Project/ProjectControllerTests.js index 3fec7ffbbb..c5f577401b 100644 --- a/services/web/test/unit/src/Project/ProjectControllerTests.js +++ b/services/web/test/unit/src/Project/ProjectControllerTests.js @@ -130,6 +130,14 @@ describe('ProjectController', function () { }, getTestSegmentation: sinon.stub().yields(null, { enabled: false }), } + this.SplitTestV2Handler = { + promises: { + getAssignment: sinon.stub().resolves({ active: false }), + assignInLocalsContext: sinon.stub().resolves(), + }, + getAssignment: sinon.stub().yields(null, { active: false }), + assignInLocalsContext: sinon.stub().yields(null), + } this.ProjectController = SandboxedModule.require(MODULE_PATH, { requires: { @@ -137,6 +145,7 @@ describe('ProjectController', function () { '@overleaf/settings': this.settings, '@overleaf/metrics': this.Metrics, '../SplitTests/SplitTestHandler': this.SplitTestHandler, + '../SplitTests/SplitTestV2Handler': this.SplitTestV2Handler, './ProjectDeleter': this.ProjectDeleter, './ProjectDuplicator': this.ProjectDuplicator, './ProjectCreationHandler': this.ProjectCreationHandler,