diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index 3bb838d974..e462cb565a 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -21,6 +21,7 @@ const { expressify } = require('../../util/promises') const OError = require('@overleaf/o-error') const SplitTestHandler = require('../SplitTests/SplitTestHandler') const SubscriptionHelper = require('./SubscriptionHelper') +const interstitialPaymentConfig = require('./interstitialPaymentConfig') const groupPlanModalOptions = Settings.groupPlanModalOptions const validGroupPlanModalOptions = { @@ -225,6 +226,28 @@ async function userSubscriptionPage(req, res) { res.render('subscriptions/dashboard', data) } +async function interstitialPaymentPage(req, res) { + const user = SessionManager.getSessionUser(req.session) + const { currencyCode: recommendedCurrency } = + await GeoIpLookup.promises.getCurrencyCode( + (req.query ? req.query.ip : undefined) || req.ip + ) + + const hasSubscription = + await LimitationsManager.promises.userHasV1OrV2Subscription(user) + + if (hasSubscription) { + res.redirect('/user/subscription?hasSubscription=true') + } else { + res.render('subscriptions/interstitial-payment', { + title: 'subscribe', + itm_content: req.query && req.query.itm_content, + recommendedCurrency, + interstitialPaymentConfig, + }) + } +} + function createSubscription(req, res, next) { const user = SessionManager.getSessionUser(req.session) const recurlyTokenIds = { @@ -560,6 +583,7 @@ module.exports = { plansPage: expressify(plansPage), paymentPage: expressify(paymentPage), userSubscriptionPage: expressify(userSubscriptionPage), + interstitialPaymentPage: expressify(interstitialPaymentPage), createSubscription, successfulSubscription, cancelSubscription, diff --git a/services/web/app/src/Features/Subscription/SubscriptionRouter.js b/services/web/app/src/Features/Subscription/SubscriptionRouter.js index 04fe88eeec..dcbdb3957c 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionRouter.js +++ b/services/web/app/src/Features/Subscription/SubscriptionRouter.js @@ -25,6 +25,12 @@ module.exports = { SubscriptionController.paymentPage ) + webRouter.get( + '/user/subscription/interstitial-payment', + AuthenticationController.requireLogin(), + SubscriptionController.interstitialPaymentPage + ) + webRouter.get( '/user/subscription/thank-you', AuthenticationController.requireLogin(), diff --git a/services/web/app/src/Features/Subscription/interstitialPaymentConfig.js b/services/web/app/src/Features/Subscription/interstitialPaymentConfig.js new file mode 100644 index 0000000000..25e08c5076 --- /dev/null +++ b/services/web/app/src/Features/Subscription/interstitialPaymentConfig.js @@ -0,0 +1,217 @@ +const config = { + tableHead: { + individual_personal: {}, + individual_collaborator: {}, + individual_professional: {}, + student_student: { + showExtraContent: true, + }, + }, + highlightedColumn: { + index: 1, + text: { + monthly: 'MOST POPULAR', + annual: 'MOST POPULAR', + }, + }, + showStudentsOnlyLabel: true, + features: [ + { + divider: false, + items: [ + { + feature: 'number_of_users', + info: 'number_of_users_info', + value: 'str', + plans: { + personal: '1 user', + collaborator: '1 user', + professional: '1 user', + student: '1 user', + }, + }, + { + feature: 'max_collab_per_project', + info: 'max_collab_per_project_info', + value: 'str', + plans: { + personal: 'You + 1', + collaborator: 'You + 10', + professional: 'Unlimited', + student: 'You + 6', + }, + }, + ], + }, + { + divider: true, + dividerLabel: 'you_and_collaborators_get_access_to', + dividerInfo: 'you_and_collaborators_get_access_to_info', + items: [ + { + feature: 'compile_timeout_short', + info: 'compile_timeout_short_info', + value: 'str', + plans: { + personal: '4 minutes', + collaborator: '4 minutes', + professional: '4 minutes', + student: '4 minutes', + }, + }, + { + feature: 'realtime_track_changes', + info: 'realtime_track_changes_info_v2', + value: 'bool', + plans: { + personal: false, + collaborator: true, + professional: true, + student: true, + }, + }, + { + feature: 'full_doc_history', + info: 'full_doc_history_info_v2', + value: 'bool', + plans: { + personal: true, + collaborator: true, + professional: true, + student: true, + }, + }, + { + feature: 'reference_search', + info: 'reference_search_info_v2', + value: 'bool', + plans: { + personal: true, + collaborator: true, + professional: true, + student: true, + }, + }, + { + feature: 'git_integration_lowercase', + info: 'git_integration_lowercase_info', + value: 'bool', + plans: { + personal: true, + collaborator: true, + professional: true, + student: true, + }, + }, + ], + }, + { + divider: true, + dividerLabel: 'you_get_access_to', + dividerInfo: 'you_get_access_to_info', + items: [ + { + feature: 'powerful_latex_editor_and_realtime_collaboration', + info: 'powerful_latex_editor_and_realtime_collaboration_info', + value: 'bool', + plans: { + personal: true, + collaborator: true, + professional: true, + student: true, + }, + }, + { + feature: 'unlimited_projects', + info: 'unlimited_projects_info', + value: 'bool', + plans: { + personal: true, + collaborator: true, + professional: true, + student: true, + }, + }, + { + feature: 'thousands_templates', + info: 'hundreds_templates_info', + value: 'bool', + plans: { + personal: true, + collaborator: true, + professional: true, + student: true, + }, + }, + { + feature: 'symbol_palette', + info: 'symbol_palette_info', + value: 'bool', + plans: { + personal: true, + collaborator: true, + professional: true, + student: true, + }, + }, + { + feature: 'github_only_integration_lowercase', + info: 'github_only_integration_lowercase_info', + value: 'bool', + plans: { + personal: true, + collaborator: true, + professional: true, + student: true, + }, + }, + { + feature: 'dropbox_integration_lowercase', + info: 'dropbox_integration_info', + value: 'bool', + plans: { + personal: true, + collaborator: true, + professional: true, + student: true, + }, + }, + { + feature: 'mendeley_integration_lowercase', + info: 'mendeley_integration_lowercase_info', + value: 'bool', + plans: { + personal: true, + collaborator: true, + professional: true, + student: true, + }, + }, + { + feature: 'zotero_integration_lowercase', + info: 'zotero_integration_lowercase_info', + value: 'bool', + plans: { + personal: true, + collaborator: true, + professional: true, + student: true, + }, + }, + { + feature: 'priority_support', + info: 'priority_support_info', + value: 'bool', + plans: { + personal: true, + collaborator: true, + professional: true, + student: true, + }, + }, + ], + }, + ], +} + +module.exports = config diff --git a/services/web/app/src/Features/Subscription/plansV2Config.js b/services/web/app/src/Features/Subscription/plansV2Config.js index d0db18895c..7e913b24fd 100644 --- a/services/web/app/src/Features/Subscription/plansV2Config.js +++ b/services/web/app/src/Features/Subscription/plansV2Config.js @@ -2,12 +2,12 @@ const plansV2Features = require('./plansV2Features') const config = { individual: { - tableHead: [ - 'individual_free', - 'individual_personal', - 'individual_collaborator', - 'individual_professional', - ], + tableHead: { + individual_free: {}, + individual_personal: {}, + individual_collaborator: {}, + individual_professional: {}, + }, features: plansV2Features.individual, highlightedColumn: { index: 2, @@ -18,11 +18,11 @@ const config = { }, }, group: { - tableHead: [ - 'group_collaborator', - 'group_professional', - 'group_organization', - ], + tableHead: { + group_collaborator: {}, + group_professional: {}, + group_organization: {}, + }, features: plansV2Features.group, highlightedColumn: { index: 1, @@ -32,7 +32,13 @@ const config = { }, }, student: { - tableHead: ['student_free', 'student_student', 'student_university'], + tableHead: { + student_free: {}, + student_student: { + showExtraContent: false, + }, + student_university: {}, + }, features: plansV2Features.student, highlightedColumn: { index: 1, diff --git a/services/web/app/views/subscriptions/interstitial-payment.pug b/services/web/app/views/subscriptions/interstitial-payment.pug new file mode 100644 index 0000000000..610b30fc34 --- /dev/null +++ b/services/web/app/views/subscriptions/interstitial-payment.pug @@ -0,0 +1,52 @@ +extends ../layout-marketing + +include ./plans-marketing/_mixins +include ./plans-marketing/_tables +include ./plans-marketing/v2/_mixins + +block vars + - entrypoint = 'pages/user/subscription/plans-v2/plans-v2-main' + +block append meta + meta(name="ol-recommendedCurrency" content=recommendedCurrency) + meta(name="ol-itm_content" content=itm_content) + +block content + main.content.content-alt#main-content + .content-page + .plans + .container + .row + .col-md-12 + .page-header.centered.plans-header.text-centered.top-page-header + h1.text-capitalize #{translate('choose_your_plan')} + + //- TODO: add analytics by adding 2 arguments in the mixin below + +monthly_annual_switch() + + .row.plans-v2-table-sticky-header.plans-v2-table-sticky-header-individual.sticky(data-ol-plans-v2-table-sticky-header='individual') + .plans-v2-table-sticky-header-item + span #{translate("personal")} + .plans-v2-table-sticky-header-item.plans-v2-table-sticky-header-item-highlighted + span #{translate("standard")} + .plans-v2-table-sticky-header-item + span #{translate("professional")} + .plans-v2-table-sticky-header-item + span #{translate("student")} + + .row.plans-v2-table-container(data-ol-plans-v2-table-container='monthly') + .col-sm-12 + .row + table.card.plans-v2-table.plans-v2-table-individual + +plans_v2_table('monthly', interstitialPaymentConfig) + + .row.plans-v2-table-container(hidden data-ol-plans-v2-table-container='annual') + .col-sm-12 + .row + table.card.plans-v2-table.plans-v2-table-individual + +plans_v2_table('annual', interstitialPaymentConfig) + + //- sticky header on mobile will be "hidden" (by removing its sticky position) if it reaches this div + .invisible(aria-hidden="true" data-ol-plans-v2-table-sticky-header-stop) + + != moduleIncludes("contactModalGeneral-marketing", locals) diff --git a/services/web/app/views/subscriptions/plans-marketing/v2/_mixins.pug b/services/web/app/views/subscriptions/plans-marketing/v2/_mixins.pug index c5b1de0ffe..903e65963f 100644 --- a/services/web/app/views/subscriptions/plans-marketing/v2/_mixins.pug +++ b/services/web/app/views/subscriptions/plans-marketing/v2/_mixins.pug @@ -2,12 +2,14 @@ mixin plans_v2_table(period, config) tr th - for (var i = 0; i < 4; i++) + - var tableHeadKey = Object.keys(config.tableHead)[i] + - var tableHeadOptions = Object.values(config.tableHead)[i] th( class=(i === config.highlightedColumn.index ? 'plans-v2-table-green-highlighted' : (i === config.highlightedColumn.index - 1 ? 'plans-v2-table-cell-before-highlighted-column' : '')) ) if (i === config.highlightedColumn.index) p.plans-v2-table-green-highlighted-text !{config.highlightedColumn.text[period]} - case config.tableHead[i] + case tableHeadKey when 'individual_free' +table_head_individual_free(period) when 'individual_personal' @@ -25,14 +27,14 @@ mixin plans_v2_table(period, config) when 'student_free' +table_head_student_free(period) when 'student_student' - +table_head_student_student(period) + +table_head_student_student(period, tableHeadOptions.showExtraContent) when 'student_university' +table_head_student_university(period) for featuresPerSection in config.features if featuresPerSection.divider tr.plans-v2-table-divider - td(colspan=config.tableHead.length + 1) + td(colspan=Object.keys(config.tableHead).length + 1) div b.plans-v2-table-divider-label #{translate(featuresPerSection.dividerLabel)} //- will only appear on screen width >= 768px (using CSS) @@ -220,7 +222,7 @@ mixin table_head_student_free(period) .plans-v2-table-btn-buy-container-desktop +btn_buy_student_free() -mixin table_head_student_student(period) +mixin table_head_student_student(period, showExtraContent) div.plans-v2-table-th-content p.plans-v2-table-th-content-title #{translate("student")} +table_head_price('student', period) @@ -229,6 +231,9 @@ mixin table_head_student_student(period) ul.plans-v2-table-th-content-benefit li !{translate("x_collaborators_per_project", {collaboratorsCount: '6'})} li #{translate("all_premium_features")} + if showExtraContent + li #{translate("for_students_only")} + .plans-v2-table-btn-buy-container-desktop +btn_buy_student_student(period) diff --git a/services/web/frontend/js/features/plans/group-plan-modal/index.js b/services/web/frontend/js/features/plans/group-plan-modal/index.js index 4a102d8132..0c2ce8aa24 100644 --- a/services/web/frontend/js/features/plans/group-plan-modal/index.js +++ b/services/web/frontend/js/features/plans/group-plan-modal/index.js @@ -131,7 +131,13 @@ document }) }) -updateGroupModalPlanPricing() +const isGroupPlanModalAvailable = document.querySelector( + '[data-ol-group-plan-modal]' +) + +if (isGroupPlanModalAvailable) { + updateGroupModalPlanPricing() +} if (window.location.hash === '#groups') { showGroupPlanModal() diff --git a/services/web/frontend/js/features/plans/plans-v2-group-plan-modal.js b/services/web/frontend/js/features/plans/plans-v2-group-plan-modal.js index 07bf2a6669..15be0779fd 100644 --- a/services/web/frontend/js/features/plans/plans-v2-group-plan-modal.js +++ b/services/web/frontend/js/features/plans/plans-v2-group-plan-modal.js @@ -63,4 +63,10 @@ document .querySelectorAll('[data-ol-group-plan-form] input') .forEach(el => el.addEventListener('change', changePlansV2MainPageGroupData)) -hideCurrencyPicker() +const isGroupPlanModalAvailable = document.querySelector( + '[data-ol-group-plan-modal]' +) + +if (isGroupPlanModalAvailable) { + hideCurrencyPicker() +} diff --git a/services/web/locales/en.json b/services/web/locales/en.json index d4f3fe7751..595cb42204 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -898,6 +898,7 @@ "for_enterprise": "For enterprise", "for_universities": "For universities", "for_students": "For students", + "for_students_only": "For students only", "get_involved": "Get involved", "become_an_advisor": "Become an __appName__ advisor", "participate_in_user_research": "Participate in user research", diff --git a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js index b408c15bb0..8fff645282 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js @@ -220,6 +220,39 @@ describe('SubscriptionController', function () { }) }) + describe('interstitialPaymentPage', function () { + beforeEach(function () { + this.req.ip = '1234.3123.3131.333 313.133.445.666 653.5345.5345.534' + this.GeoIpLookup.promises.getCurrencyCode.resolves({ + currencyCode: this.stubbedCurrencyCode, + }) + }) + + describe('with a user without subscription', function () { + it('should render the interstitial payment page', function (done) { + this.res.render = (page, opts) => { + page.should.equal('subscriptions/interstitial-payment') + done() + } + this.SubscriptionController.interstitialPaymentPage(this.req, this.res) + }) + }) + + describe('with a user with subscription', function () { + it('should redirect to the subscription dashboard', function (done) { + this.PlansLocator.findLocalPlanInSettings.returns({}) + this.LimitationsManager.promises.userHasV1OrV2Subscription.resolves( + true + ) + this.res.redirect = url => { + url.should.equal('/user/subscription?hasSubscription=true') + done() + } + this.SubscriptionController.interstitialPaymentPage(this.req, this.res) + }) + }) + }) + describe('paymentPage', function () { beforeEach(function () { this.req.headers = {}