diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index fbca68e115..27d32da407 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -18,6 +18,17 @@ const AnalyticsManager = require('../Analytics/AnalyticsManager') const RecurlyEventHandler = require('./RecurlyEventHandler') const { expressify } = require('../../util/promises') const OError = require('@overleaf/o-error') +const { + getAssignmentForSession, +} = require('../SplitTests/SplitTestV2Handler').promises + +const groupPlanModalOptions = Settings.groupPlanModalOptions +const validGroupPlanModalOptions = { + plan_code: groupPlanModalOptions.plan_codes.map(item => item.code), + currency: groupPlanModalOptions.currencies.map(item => item.code), + size: groupPlanModalOptions.sizes, + usage: groupPlanModalOptions.usages.map(item => item.code), +} async function plansPage(req, res) { const plans = SubscriptionViewModelBuilder.buildPlansList() @@ -28,14 +39,46 @@ async function plansPage(req, res) { (req.query ? req.query.ip : undefined) || req.ip ) - res.render('subscriptions/plans', { + function getDefault(param, category, defaultValue) { + const v = req.query && req.query[param] + if (v && validGroupPlanModalOptions[category].includes(v)) { + return v + } + return defaultValue + } + + let defaultGroupPlanModalCurrency = 'USD' + if (validGroupPlanModalOptions.currency.includes(recommendedCurrency)) { + defaultGroupPlanModalCurrency = recommendedCurrency + } + const groupPlanModalDefaults = { + plan_code: getDefault('plan', 'plan_code', 'collaborator'), + size: getDefault('number', 'size', '10'), + currency: getDefault('currency', 'currency', defaultGroupPlanModalCurrency), + usage: getDefault('usage', 'usage', 'enterprise'), + } + + const { variant: templateVariant } = await getAssignmentForSession( + req.session, + 'plans-page-de-ng' + ) + const template = + templateVariant === 'de-ng' + ? 'subscriptions/plans-marketing' + : 'subscriptions/plans' + + res.render(template, { title: 'plans_and_pricing', plans, gaExperiments: Settings.gaExperiments.plansPage, gaOptimize: true, + itm_content: req.query && req.query.itm_content, recomendedCurrency: recommendedCurrency, + recommendedCurrency, planFeatures, groupPlans: GroupPlansData, + groupPlanModalOptions, + groupPlanModalDefaults, }) } diff --git a/services/web/app/views/subscriptions/_plans_faq.pug b/services/web/app/views/subscriptions/_plans_faq.pug index 4fc39e49f4..12d881cd12 100644 --- a/services/web/app/views/subscriptions/_plans_faq.pug +++ b/services/web/app/views/subscriptions/_plans_faq.pug @@ -6,7 +6,7 @@ .row .col-md-6 h3 #{translate("faq_how_free_trial_works_question")} - p #{translate('faq_how_does_free_trial_works_answer', { appName:'{{settings.appName}}', len:'{{trial_len}}' })} + p #{translate('faq_how_does_free_trial_works_answer', { appName:'{{settings.appName}}', len:'7' })} .col-md-6 h3 #{translate('faq_change_plans_question')} p #{translate('faq_change_plans_answer')} diff --git a/services/web/app/views/subscriptions/_plans_page_mixins.pug b/services/web/app/views/subscriptions/_plans_page_mixins.pug index 7ed35769a9..773a77693d 100644 --- a/services/web/app/views/subscriptions/_plans_page_mixins.pug +++ b/services/web/app/views/subscriptions/_plans_page_mixins.pug @@ -22,7 +22,7 @@ mixin btn_buy_free(location) span.text-capitalize #{translate('get_started_now')} mixin btn_buy_professional(location) a.btn.btn-primary( - ng-href="/user/subscription/new?planCode=professional{{ ui.view == 'annual' && '-annual' || planQueryString}}¤cy={{currencyCode}}&itm_campaign=plans&itm_content=" + location, + ng-href="/user/subscription/new?planCode=professional{{ ui.view == 'annual' && '-annual' || '_free_trial_7_days'}}¤cy={{currencyCode}}&itm_campaign=plans&itm_content=" + location, ng-click="signUpNowClicked('professional','" + location + "')" ) span(ng-show="ui.view != 'annual'") #{translate("start_free_trial")} @@ -34,9 +34,8 @@ mixin btn_buy_student(location, plan) ng-click="signUpNowClicked('student-annual','" + location + "')" ) #{translate("buy_now")} else - //- planQueryString will contain _free_trial_7_days a.btn.btn-primary( - ng-href="/user/subscription/new?planCode=student{{planQueryString}}¤cy={{currencyCode}}&itm_campaign=plans&itm_content=" + location, + ng-href="/user/subscription/new?planCode=student_free_trial_7_days¤cy={{currencyCode}}&itm_campaign=plans&itm_content=" + location, ng-click="signUpNowClicked('student-monthly','" + location + "')" ) #{translate("start_free_trial")} diff --git a/services/web/app/views/subscriptions/plans-marketing.pug b/services/web/app/views/subscriptions/plans-marketing.pug new file mode 100644 index 0000000000..12338591aa --- /dev/null +++ b/services/web/app/views/subscriptions/plans-marketing.pug @@ -0,0 +1,104 @@ +extends ../layout-marketing + +include ./plans-marketing/_mixins +include ./plans-marketing/_tables + +block vars + - metadata = { viewport: true } + - entrypoint = 'pages/user/subscription/plans' + +block append meta + meta(name="ol-recommendedCurrency" content=recommendedCurrency) + meta(name="ol-groupPlans" data-type="json" content=groupPlans) + meta(name="ol-currencySymbols" data-type="json" content=groupPlanModalOptions.currencySymbols) + meta(name="ol-itm_content" content=itm_content) + +block content + main.content.content-alt#main-content + .container + .user-notifications + ul.list-unstyled(ng-cloak) + li.notification-entry + .alert.alert-info + .notification-body + span To help you work from home throughout 2021, we're providing discounted plans and special initiatives. + .notification-action + a.btn.btn-sm.btn-info(href="https://www.overleaf.com/events/wfh2021" event-tracking="Event-Pages" event-tracking-trigger="click" event-tracking-ga="WFH-Offer-Click" event-tracking-label="Plans-Banner") Upgrade + .content-page + .plans + .container(ng-cloak) + .row + .col-md-12 + .page-header.centered.plans-header.text-centered + h1.text-capitalize(ng-non-bindable) #{translate('get_instant_access_to')} #{settings.appName} + .row + .col-md-8.col-md-offset-2 + p.text-centered #{translate("sl_benefits_plans")} + + +allCardsAndControls() + + .row.row-spaced-large.text-centered + .col-xs-12 + p.text-centered !{translate('also_provides_free_plan', {}, [{ name: 'a', attrs: { href: '/register' }}])} + i.fa.fa-cc-mastercard.fa-2x(aria-hidden="true")   + span.sr-only Mastercard accepted + i.fa.fa-cc-visa.fa-2x(aria-hidden="true")   + span.sr-only Visa accepted + i.fa.fa-cc-amex.fa-2x(aria-hidden="true")   + span.sr-only Amex accepted + i.fa.fa-cc-paypal.fa-2x(aria-hidden="true")   + span.sr-only Paypal accepted + div.text-centered #{translate('change_plans_any_time')}
#{translate('billed_after_x_days', {len:'7'})} + br + div.text-centered #{translate('subject_to_additional_vat')}
#{translate('select_country_vat')} + + .row.row-spaced-large + .col-md-8.col-md-offset-2 + .card.text-centered + .card-header + h2 #{translate('looking_multiple_licenses')} + span #{translate('reduce_costs_group_licenses')} + br + br + a.btn.btn-default( + href="#groups" + data-ol-open-group-plan-modal + ) #{translate('find_out_more')} + + .row.row-spaced-large + .col-sm-12 + .page-header.plans-header.plans-subheader.text-centered + h2 #{translate('compare_plan_features')} + .row + .col-md-6.col-md-offset-3 + +plan_switch('table') + .col-md-3.text-right + +currency_dropdown + .row(event-tracking="features-table-viewed" event-tracking-ga="subscription-funnel" event-tracking-trigger="scroll" event-tracking-send-once="true" event-tracking-label="exp-") + .col-sm-12(data-ol-view='monthly') + +table_premium + .col-sm-12(hidden data-ol-view='annual') + +table_premium + .col-sm-12(hidden data-ol-view='student') + +table_student + + include ./plans-marketing/_quotes + + include ./plans-marketing/_faq + + #bottom-cards.row.row-spaced(style="display: none;") + .col-sm-12 + +allCardsAndControls(true, 'bottom') + + .row.row-spaced-large + .col-md-12 + .plans-header.plans-subheader.text-centered + h2.header-with-btn #{translate('still_have_questions')} + button.btn.btn-default.btn-header.text-capitalize( + data-ol-open-contact-form-modal="general" + ) #{translate('get_in_touch')} + + .row.row-spaced + + include ./plans-marketing/_group_plan_modal + != moduleIncludes("contactModalGeneral-marketing", locals) diff --git a/services/web/app/views/subscriptions/plans-marketing/_faq.pug b/services/web/app/views/subscriptions/plans-marketing/_faq.pug new file mode 100644 index 0000000000..8502c91c0d --- /dev/null +++ b/services/web/app/views/subscriptions/plans-marketing/_faq.pug @@ -0,0 +1,34 @@ +.faq + .row.row-spaced-large + .col-md-12 + .page-header.plans-header.plans-subheader.text-centered + h2 FAQ + .row + .col-md-6 + h3 #{translate("faq_how_free_trial_works_question")} + p #{translate('faq_how_does_free_trial_works_answer', { len:'7' })} + .col-md-6 + h3 #{translate('faq_change_plans_question')} + p #{translate('faq_change_plans_answer')} + .row + .col-md-6 + h3 #{translate('faq_do_collab_need_premium_question')} + p #{translate('faq_do_collab_need_premium_answer')} + .col-md-6 + h3 #{translate('faq_need_more_collab_question')} + p !{translate('faq_need_more_collab_answer', { referFriendsLink: translate('referring_your_friends') })} + .row + .col-md-6 + h3 #{translate('faq_purchase_more_licenses_question')} + p !{translate('faq_purchase_more_licenses_answer', { groupLink: translate('discounted_group_accounts') })}  + a(href='#groups', ng-click="openGroupPlanModal()") #{translate("get_in_touch_for_details")} + .col-md-6 + h3 #{translate('faq_monthly_or_annual_question')} + p #{translate('faq_monthly_or_annual_answer')} + .row + .col-md-6 + h3 #{translate('faq_how_to_pay_question')} + p #{translate('faq_how_to_pay_answer')} + .col-md-6 + h3 #{translate('faq_pay_by_invoice_question')} + p !{translate('faq_pay_by_invoice_answer', {}, [{ name: 'a', attrs: { href: "#pay-by-invoice", 'ng-controller': "ContactGeneralModal", 'ng-click': "openModal()" }}])} diff --git a/services/web/app/views/subscriptions/plans-marketing/_group_plan_modal.pug b/services/web/app/views/subscriptions/plans-marketing/_group_plan_modal.pug new file mode 100644 index 0000000000..f4bad378a0 --- /dev/null +++ b/services/web/app/views/subscriptions/plans-marketing/_group_plan_modal.pug @@ -0,0 +1,86 @@ +div.modal.fade(tabindex="-1" role="dialog" data-ol-group-plan-modal) + .modal-dialog(role="document") + .modal-content + .modal-header + h3 Save 30% or more with a group license + .modal-body.plans + .container-fluid + .row + .col-md-6.text-center + .circle.circle-lg + span(data-ol-group-plan-display-price) ... + span.small / year + br + span.circle-subtext(data-ol-group-plan-for-n-users) For ... users + ul.list-unstyled + li Each user will have access to: + li   + li( + hidden=(groupPlanModalDefaults.plan_code !== 'collaborator') + data-ol-group-plan-plan-code='collaborator' + ) + strong #{translate("collabs_per_proj", {collabcount:10})} + li( + hidden=(groupPlanModalDefaults.plan_code !== 'professional') + data-ol-group-plan-plan-code='professional' + ) + strong #{translate("unlimited_collabs")} + +features_premium + .col-md-6 + form.form(data-ol-group-plan-form) + .form-group + label(for='plan_code') + | Plan + select.form-control(id="plan_code") + for plan_code in groupPlanModalOptions.plan_codes + option( + value=plan_code.code + selected=(plan_code.code === groupPlanModalDefaults.plan_code) + ) #{plan_code.display} + .form-group + label(for='size') + | Number of users + select.form-control(id="size") + for size in groupPlanModalOptions.sizes + option( + value=size + selected=(size === groupPlanModalDefaults.size) + ) #{size} + .form-group + label(for='currency') + | Currency + select.form-control(id="currency") + for currency in groupPlanModalOptions.currencies + option( + value=currency.code + selected=(currency.code === groupPlanModalDefaults.currency) + ) #{currency.display} + .form-group + label(for='usage') + | Usage + select.form-control(id="usage") + for usage in groupPlanModalOptions.usages + option( + value=usage.code + selected=(usage.code === groupPlanModalDefaults.usage) + ) #{usage.display} + p.small.text-center.row-spaced-small + span( + hidden=(groupPlanModalDefaults.usage !== 'educational') + data-ol-group-plan-usage='educational' + ) + | The 40% educational discount can be used by students or faculty using Overleaf for teaching + span( + hidden=(groupPlanModalDefaults.usage !== 'enterprise') + data-ol-group-plan-usage='enterprise' + ) + | Save an additional 40% on groups of 10 or more with our educational discount + .modal-footer + .text-center + button.btn.btn-primary.btn-lg(data-ol-purchase-group-plan) Purchase Now + hr.thin + a( + href + data-ol-open-contact-form-for-more-than-50-licenses + ) Need more than 50 licenses? Please get in touch + diff --git a/services/web/app/views/subscriptions/plans-marketing/_mixins.pug b/services/web/app/views/subscriptions/plans-marketing/_mixins.pug new file mode 100644 index 0000000000..8e621ce647 --- /dev/null +++ b/services/web/app/views/subscriptions/plans-marketing/_mixins.pug @@ -0,0 +1,250 @@ +//- Buy Buttons +mixin btn_buy_collaborator(location) + a.btn.btn-primary( + data-ol-start-new-subscription='collaborator' + data-ol-location=location + ) + span(data-ol-view='monthly') #{translate("start_free_trial")} + span(hidden data-ol-view='annual') #{translate("buy_now")} +mixin btn_buy_personal(location) + a.btn.btn-primary( + data-ol-start-new-subscription='paid-personal' + data-ol-tracking-plan='personal' + data-ol-location=location + ) + span(data-ol-view='monthly') #{translate("start_free_trial")} + span(hidden data-ol-view='annual') #{translate("buy_now")} +mixin btn_buy_free(location) + a.btn.btn-primary( + data-ol-has-custom-href + href="/register" + style=(getLoggedInUserId() === null ? "" : "visibility: hidden") + data-ol-start-new-subscription='free' + data-ol-location=location + ) + span.text-capitalize #{translate('get_started_now')} +mixin btn_buy_professional(location) + a.btn.btn-primary( + data-ol-start-new-subscription='professional' + data-ol-location=location + ) + span(data-ol-view='monthly') #{translate("start_free_trial")} + span(hidden data-ol-view='annual') #{translate("buy_now")} +mixin btn_buy_student(location, plan) + if plan == 'annual' + a.btn.btn-primary( + data-ol-start-new-subscription='student' + data-ol-item-view='annual' + data-ol-tracking-label='student-annual' + data-ol-location=location + ) #{translate("buy_now")} + else + a.btn.btn-primary( + data-ol-start-new-subscription='student' + data-ol-item-view='monthly' + data-ol-tracking-label='student-monthly' + data-ol-location=location + ) #{translate("start_free_trial")} + +//- Cards +mixin card_student_annual(location) + .best-value + strong #{translate('best_value')} + .card-header + h2 #{translate("student")} (#{translate("annual")}) + h5.tagline #{translate('tagline_student_annual')} + .circle + span + +price_student_annual + +features_student(location, 'annual') +mixin card_student_monthly(location) + .card-header + h2 #{translate("student")} + h5.tagline #{translate('tagline_student_monthly')} + .circle + span + +price_student_monthly + +features_student(location, 'monthly') + +//- Features Lists, used within cards +mixin features_collaborator(location) + ul.list-unstyled + li + strong #{translate("collabs_per_proj", {collabcount:10})} + +features_premium + li + br + +btn_buy_collaborator(location) +mixin features_free(location) + ul.list-unstyled + li #{translate("one_collaborator")} + li(class="hidden-xs hidden-sm")   + li(class="hidden-xs hidden-sm")   + li(class="hidden-xs hidden-sm")   + li(class="hidden-xs hidden-sm")   + li(class="hidden-xs hidden-sm")   + li(class="hidden-xs hidden-sm")   + li + br + +btn_buy_free(location) +mixin features_personal(location) + ul.list-unstyled + li #{translate("one_collaborator")} + li   + li + strong #{translate('premium_features')} + li #{translate('sync_dropbox_github')} + li #{translate('full_doc_history')} + li + #{translate('more').toLowerCase()} + li(class="hidden-xs hidden-sm")   + li + br + +btn_buy_personal(location) +mixin features_premium + li   + li + strong #{translate('all_premium_features')} + li #{translate('sync_dropbox_github')} + li #{translate('full_doc_history')} + li #{translate('track_changes')} + li + #{translate('more').toLowerCase()} +mixin features_professional(location) + ul.list-unstyled + li + strong #{translate("unlimited_collabs")} + +features_premium + li + br + +btn_buy_professional(location) +mixin features_student(location, plan) + ul.list-unstyled + li + strong #{translate("collabs_per_proj", {collabcount:6})} + +features_premium + li + br + +btn_buy_student(location, plan) + +mixin gen_localized_price_for_plan_view(plan, view) + for currencyCode in Object.keys(settings.localizedPlanPricing) + span( + hidden=(currencyCode !== recommendedCurrency) + data-ol-currencyCode=currencyCode + ) #{settings.localizedPlanPricing[currencyCode][plan][view]} + +mixin gen_localized_price_for_plan(plan) + div(data-ol-view='monthly') + +gen_localized_price_for_plan_view(plan, 'monthly') + span.small /mo + div(hidden data-ol-view='annual') + +gen_localized_price_for_plan_view(plan, 'annual') + span.small /yr + +//- Prices +mixin price_personal + +gen_localized_price_for_plan('personal') +mixin price_collaborator + +gen_localized_price_for_plan('collaborator') +mixin price_professional + +gen_localized_price_for_plan('professional') +mixin price_student_annual + +gen_localized_price_for_plan_view('student', 'annual') + span.small /yr +mixin price_student_monthly + +gen_localized_price_for_plan_view('student', 'monthly') + span.small /mo + +//- UI Control +mixin currency_dropdown + .dropdown.currency-dropdown(dropdown) + a.btn.btn-default.dropdown-toggle( + href="#", + data-toggle="dropdown", + dropdown-toggle + ) + for currencyCode in Object.keys(settings.localizedPlanPricing) + span( + hidden=(currencyCode !== recommendedCurrency) + data-ol-currencyCode=currencyCode + ) #{currencyCode} (#{settings.localizedPlanPricing[currencyCode]['symbol']}) + span.caret + + ul.dropdown-menu.dropdown-menu-right.text-right(role="menu") + for currencyCode in Object.keys(settings.localizedPlanPricing) + li + a( + href='#' + data-ol-currencyCode-switch=currencyCode + ) #{currencyCode} #{settings.localizedPlanPricing[currencyCode]['symbol']} + +mixin plan_switch(location) + ul.nav.nav-pills + li.active(data-ol-view-tab='monthly') + a.btn.btn-default-outline( + href="#" + ) #{translate("monthly")} + li(data-ol-view-tab='annual') + a.btn.btn-default-outline( + href="#" + ) #{translate("annual")} + li(data-ol-view-tab='student') + a.btn.btn-default-outline( + href="#" + ) #{translate("special_price_student")} + +mixin allCardsAndControls(controlsRowSpaced, listLocation) + - var location = listLocation ? 'card_' + listLocation : 'card' + .row.top-switch(class=(controlsRowSpaced ? "row-spaced" : "")) + .col-md-6.col-md-offset-3 + +plan_switch('card') + .col-md-2.text-right + +currency_dropdown + + .row + .col-md-10.col-md-offset-1 + .row + for view in ['monthly', 'annual'] + .card-group.text-centered(data-ol-view=view hidden=(view==='annual')) + .col-md-4 + .card.card-first + .card-header + h2 #{translate("personal")} + h5.tagline #{translate("tagline_personal")} + .circle + +price_personal + +features_personal(location) + .col-md-4 + .card.card-highlighted + .best-value + strong #{translate('best_value')} + .card-header + h2 #{translate("collaborator")} + h5.tagline #{translate("tagline_collaborator")} + .circle + +price_collaborator + +features_collaborator(location) + .col-md-4 + .card.card-last + .card-header + h2 #{translate("professional")} + h5.tagline #{translate("tagline_professional")} + .circle + +price_professional + +features_professional(location) + + .card-group.text-centered(hidden data-ol-view='student') + .col-md-4 + .card.card-first + .card-header + h2 #{translate("free")} + h5.tagline #{translate("tagline_free")} + .circle #{translate("free")} + +features_free(location) + + .col-md-4 + .card.card-highlighted + +card_student_annual(location) + + .col-md-4 + .card.card-last + +card_student_monthly(location) diff --git a/services/web/app/views/subscriptions/plans-marketing/_quotes.pug b/services/web/app/views/subscriptions/plans-marketing/_quotes.pug new file mode 100644 index 0000000000..e7c6978a48 --- /dev/null +++ b/services/web/app/views/subscriptions/plans-marketing/_quotes.pug @@ -0,0 +1,25 @@ +.row.row-spaced-large + .col-md-12 + .page-header.plans-header.plans-subheader.text-centered + h2 #{translate('in_good_company')} +.row + .col-md-6 + div + .row + .col-md-3 + .circle-img + img(src=buildImgPath('advocates/schultz.jpg') alt="Kevin Schultz") + .col-md-9 + blockquote + p It is the ability to collaborate very easily that drew me to Overleaf. + footer Kevin Schultz, Assistant Professor of Physics, Hartwick College + .col-md-6 + div + .row + .col-md-3 + .circle-img + img(src=buildImgPath('advocates/dagoret-campagne.jpg') alt="Dr Sylvie Dagoret-Campagne") + .col-md-9 + blockquote + p Overleaf is a great educational tool for publishing scientific documents. + footer Dr Sylvie Dagoret-Campagne, Director of Research at CNRS, University of Paris-Saclay \ No newline at end of file diff --git a/services/web/app/views/subscriptions/plans-marketing/_tables.pug b/services/web/app/views/subscriptions/plans-marketing/_tables.pug new file mode 100644 index 0000000000..846ddaa040 --- /dev/null +++ b/services/web/app/views/subscriptions/plans-marketing/_tables.pug @@ -0,0 +1,116 @@ +//- Features Tables +mixin table_premium + table.card.plans-table.plans-table-main + tr + th + th #{translate("free")} + th #{translate("personal")} + th #{translate("collaborator")} + .outer.outer-top + .outer-content + .best-value + strong #{translate('best_value')} + th #{translate("professional")} + + tr + td #{translate("price")} + td #{translate("free")} + td + +price_personal + td + +price_collaborator + td + +price_professional + + for feature in planFeatures + tr + td(event-tracking="features-table" event-tracking-trigger="hover" event-tracking-ga="subscription-funnel" event-tracking-label=`${feature.feature}`) + if feature.info + span(tooltip=translate(feature.info)) #{translate(feature.feature)} + else + | #{translate(feature.feature)} + for plan in feature.plans + td(ng-non-bindable) + if feature.value == 'str' + | #{plan} + else if plan + i.fa.fa-check(aria-hidden="true") + span.sr-only Feature included + else + i.fa.fa-times(aria-hidden="true") + span.sr-only Feature not included + + tr + td + td + +btn_buy_free('table') + td + +btn_buy_personal('table') + td + +btn_buy_collaborator('table') + .outer.outer-btm + .outer-content   + td + +btn_buy_professional('table') + +mixin table_cell_student(feature) + if feature.value == 'str' + | #{feature.student} + else if feature.student + i.fa.fa-check(aria-hidden="true") + span.sr-only Feature included + else + i.fa.fa-times(aria-hidden="true") + span.sr-only Feature not included + +mixin table_student + table.card.plans-table.plans-table-student + tr + th + th #{translate("free")} + th #{translate("student")} (#{translate("annual")}) + .outer.outer-top + .outer-content + .best-value + strong Best Value + th #{translate("student")} + + tr + td #{translate("price")} + td #{translate("free")} + td + +price_student_annual + td + +price_student_monthly + + for feature in planFeatures + tr + td(event-tracking="plans-page-table" event-tracking-trigger="hover" event-tracking-ga="subscription-funnel" event-tracking-label=`${feature.feature}`) + if feature.info + span(tooltip=translate(feature.info)) #{translate(feature.feature)} + else + | #{translate(feature.feature)} + td(ng-non-bindable) + if feature.value == 'str' + | #{feature.plans.free} + else if feature.plans.free + i.fa.fa-check(aria-hidden="true") + span.sr-only Feature included + else + i.fa.fa-times(aria-hidden="true") + span.sr-only Feature included + td(ng-non-bindable) + +table_cell_student(feature) + td(ng-non-bindable) + +table_cell_student(feature) + + tr + td + td + +btn_buy_free('table') + td + +btn_buy_student('table', 'annual') + .outer.outer-btm + .outer-content   + td + +btn_buy_student('table', 'monthly') diff --git a/services/web/app/views/subscriptions/plans.pug b/services/web/app/views/subscriptions/plans.pug index 1db7c400ec..5b9e4caf29 100644 --- a/services/web/app/views/subscriptions/plans.pug +++ b/services/web/app/views/subscriptions/plans.pug @@ -45,7 +45,7 @@ block content span.sr-only Amex accepted i.fa.fa-cc-paypal.fa-2x(aria-hidden="true")   span.sr-only Paypal accepted - div.text-centered #{translate('change_plans_any_time')}
#{translate('billed_after_x_days', {len:'{{trial_len}}'})} + div.text-centered #{translate('change_plans_any_time')}
#{translate('billed_after_x_days', {len:'7'})} br div.text-centered #{translate('subject_to_additional_vat')}
#{translate('select_country_vat')} @@ -71,7 +71,7 @@ block content +plan_switch('table') .col-md-3.text-right +currency_dropdown - .row(event-tracking="features-table-viewed" event-tracking-ga="subscription-funnel" event-tracking-trigger="scroll" event-tracking-send-once="true" event-tracking-label=`exp-{{plansVariant}}`) + .row(event-tracking="features-table-viewed" event-tracking-ga="subscription-funnel" event-tracking-trigger="scroll" event-tracking-send-once="true" event-tracking-label="exp-") .col-sm-12(ng-if="ui.view != 'student'") +table_premium .col-sm-12(ng-if="ui.view == 'student'") diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index ce5efe71ea..0bf0c52560 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -300,6 +300,12 @@ module.exports = { personal: defaultFeatures, }, + groupPlanModalOptions: { + plan_codes: [], + currencies: [], + sizes: [], + usages: [], + }, plans: [ { planCode: 'personal', diff --git a/services/web/frontend/js/features/contact-form/index.js b/services/web/frontend/js/features/contact-form/index.js index f6ccae61b6..55ab27a34d 100644 --- a/services/web/frontend/js/features/contact-form/index.js +++ b/services/web/frontend/js/features/contact-form/index.js @@ -7,6 +7,15 @@ document document.querySelectorAll('a[ng-click="contactUsModal()"]').forEach(el => { el.addEventListener('click', function (e) { e.preventDefault() - $('[data-ol-contact-form-modal]').modal() + $('[data-ol-contact-form-modal="contact-us"]').modal() }) }) + +document + .querySelectorAll('[data-ol-open-contact-form-modal="general"]') + .forEach(el => { + el.addEventListener('click', function (e) { + e.preventDefault() + $('[data-ol-contact-form-modal="general"]').modal() + }) + }) 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 new file mode 100644 index 0000000000..455eafeb55 --- /dev/null +++ b/services/web/frontend/js/features/plans/group-plan-modal/index.js @@ -0,0 +1,106 @@ +import getMeta from '../../../utils/meta' +import { swapModal } from '../../utils/swapModal' +import * as eventTracking from '../../../infrastructure/event-tracking' + +function getFormValues() { + const modalEl = document.querySelector('[data-ol-group-plan-modal]') + const planCode = modalEl.querySelector('#plan_code').value + const size = modalEl.querySelector('#size').value + const currency = modalEl.querySelector('#currency').value + const usage = modalEl.querySelector('#usage').value + return { planCode, size, currency, usage } +} + +function updateGroupPlanView() { + const prices = getMeta('ol-groupPlans') + const currencySymbols = getMeta('ol-currencySymbols') + + const modalEl = document.querySelector('[data-ol-group-plan-modal]') + const { planCode, size, currency, usage } = getFormValues() + + const price = prices[usage][planCode][currency][size] + const currencySymbol = currencySymbols[currency] + const displayPrice = `${currencySymbol}${price}` + + modalEl.querySelectorAll('[data-ol-group-plan-plan-code]').forEach(el => { + el.hidden = el.getAttribute('data-ol-group-plan-plan-code') !== planCode + }) + modalEl.querySelectorAll('[data-ol-group-plan-usage]').forEach(el => { + el.hidden = el.getAttribute('data-ol-group-plan-usage') !== usage + }) + modalEl.querySelector( + '[data-ol-group-plan-display-price]' + ).innerText = displayPrice + modalEl.querySelector( + '[data-ol-group-plan-for-n-users]' + ).innerText = `For ${size} users` +} + +const modalEl = $('[data-ol-group-plan-modal]') +modalEl + .on('shown.bs.modal', function () { + const path = `${window.location.pathname}${window.location.search}` + history.replaceState(null, document.title, path + '#groups') + }) + .on('hidden.bs.modal', function () { + history.replaceState(null, document.title, window.location.pathname) + }) + +function showGroupPlanModal() { + modalEl.modal() + eventTracking.send( + 'subscription-funnel', + 'plans-page', + 'group-inquiry-potential' + ) +} + +document + .querySelectorAll('[data-ol-group-plan-form] select') + .forEach(el => el.addEventListener('change', updateGroupPlanView)) +document.querySelectorAll('[data-ol-purchase-group-plan]').forEach(el => + el.addEventListener('click', e => { + e.preventDefault() + + const { planCode, size, currency, usage } = getFormValues() + const queryParams = new URLSearchParams( + Object.entries({ + planCode: `group_${planCode}_${size}_${usage}`, + currency, + itm_campaign: 'groups', + }) + ) + const itmContent = getMeta('ol-itm_content') + if (itmContent) { + queryParams.set('itm_content', itmContent) + } + const url = new URL('/user/subscription/new', window.origin) + url.search = queryParams.toString() + window.location = url.toString() + }) +) + +document.querySelectorAll('[data-ol-open-group-plan-modal]').forEach(el => { + el.addEventListener('click', function (e) { + e.preventDefault() + showGroupPlanModal() + }) +}) + +document + .querySelectorAll('[data-ol-open-contact-form-for-more-than-50-licenses]') + .forEach(el => { + el.addEventListener('click', function (e) { + e.preventDefault() + swapModal( + '[data-ol-group-plan-modal]', + '[data-ol-contact-form-modal="general"]' + ) + }) + }) + +updateGroupPlanView() + +if (window.location.hash === '#groups') { + showGroupPlanModal() +} diff --git a/services/web/frontend/js/features/utils/swapModal.js b/services/web/frontend/js/features/utils/swapModal.js new file mode 100644 index 0000000000..49df6386d2 --- /dev/null +++ b/services/web/frontend/js/features/utils/swapModal.js @@ -0,0 +1,12 @@ +export function swapModal(selectorBefore, selectorAfter) { + const modalBefore = $(selectorBefore) + const modalAfter = $(selectorAfter) + + // Disable the fade-out + fade-in animation when swapping the forms. + modalBefore.removeClass('fade') + modalAfter.removeClass('fade') + modalAfter.modal() + modalBefore.modal('hide') + modalBefore.addClass('fade') + modalAfter.addClass('fade') +} diff --git a/services/web/frontend/js/main/plans.js b/services/web/frontend/js/main/plans.js index 478c139f5d..6409ca74b5 100644 --- a/services/web/frontend/js/main/plans.js +++ b/services/web/frontend/js/main/plans.js @@ -248,10 +248,6 @@ App.controller( $scope.currencyCode = MultiCurrencyPricing.currencyCode - $scope.trial_len = 7 - - $scope.planQueryString = '_free_trial_7_days' - $scope.ui = { view: 'monthly' } $scope.changeCurreny = function (e, newCurrency) { @@ -265,7 +261,7 @@ App.controller( if (view === 'annual') { return 'collaborator-annual' } else { - return `collaborator${$scope.planQueryString}` + return `collaborator_free_trial_7_days` } } @@ -274,7 +270,7 @@ App.controller( if (view === 'annual') { return 'paid-personal-annual' } else { - return `paid-personal${$scope.planQueryString}` + return `paid-personal_free_trial_7_days` } } diff --git a/services/web/frontend/js/pages/user/subscription/plans.js b/services/web/frontend/js/pages/user/subscription/plans.js new file mode 100644 index 0000000000..2c24cddf84 --- /dev/null +++ b/services/web/frontend/js/pages/user/subscription/plans.js @@ -0,0 +1,78 @@ +import '../../../marketing' +import '../../../features/plans/group-plan-modal' + +import * as eventTracking from '../../../infrastructure/event-tracking' +import getMeta from '../../../utils/meta' + +let currentView = 'monthly' +let currentCurrencyCode = getMeta('ol-recommendedCurrency') + +function setUpViewSwitching(liEl) { + const view = liEl.getAttribute('data-ol-view-tab') + liEl.querySelector('a').addEventListener('click', function (e) { + e.preventDefault() + eventTracking.send('subscription-funnel', 'plans-page', `${view}-prices`) + document.querySelectorAll('[data-ol-view-tab]').forEach(el => { + if (el.getAttribute('data-ol-view-tab') === view) { + el.classList.add('active') + } else { + el.classList.remove('active') + } + }) + document.querySelectorAll('[data-ol-view]').forEach(el => { + el.hidden = el.getAttribute('data-ol-view') !== view + }) + currentView = view + updateLinkTargets() + }) +} + +function setUpCurrencySwitching(linkEl) { + const currencyCode = linkEl.getAttribute('data-ol-currencyCode-switch') + linkEl.addEventListener('click', function (e) { + e.preventDefault() + document.querySelectorAll('[data-ol-currencyCode]').forEach(el => { + el.hidden = el.getAttribute('data-ol-currencyCode') !== currencyCode + }) + currentCurrencyCode = currencyCode + updateLinkTargets() + }) +} + +function setUpSubscriptionTracking(linkEl) { + const plan = + linkEl.getAttribute('data-ol-tracking-plan') || + linkEl.getAttribute('data-ol-start-new-subscription') + + linkEl.addEventListener('click', function () { + const customLabel = linkEl.getAttribute('data-ol-tracking-label') + const computedLabel = currentView === 'annual' ? `${plan}_annual` : plan + const label = customLabel || computedLabel + + eventTracking.sendMB('plans-page-start-trial') + eventTracking.send('subscription-funnel', 'sign_up_now_button', label) + }) +} + +function updateLinkTargets() { + document.querySelectorAll('[data-ol-start-new-subscription]').forEach(el => { + if (el.hasAttribute('data-ol-has-custom-href')) return + + const plan = el.getAttribute('data-ol-start-new-subscription') + const view = el.getAttribute('data-ol-item-view') || currentView + const suffix = view === 'annual' ? `_annual` : `_free_trial_7_days` + const planCode = `${plan}${suffix}` + + const location = el.getAttribute('data-ol-location') + el.href = `/user/subscription/new?planCode=${planCode}¤cy=${currentCurrencyCode}&itm_campaign=plans&itm_content=${location}` + }) +} + +document.querySelectorAll('[data-ol-view-tab]').forEach(setUpViewSwitching) +document + .querySelectorAll('[data-ol-currencyCode-switch]') + .forEach(setUpCurrencySwitching) +document + .querySelectorAll('[data-ol-start-new-subscription]') + .forEach(setUpSubscriptionTracking) +updateLinkTargets() diff --git a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js index 27d80343d3..4e0cc322e0 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js @@ -99,6 +99,17 @@ describe('SubscriptionController', function () { collaborator: 'COLLABORATORCODEHERE', }, }, + groupPlanModalOptions: { + plan_codes: [], + currencies: [ + { + display: 'GBP (£)', + code: 'GBP', + }, + ], + sizes: ['42'], + usages: [{ code: 'foo', display: 'Foo' }], + }, apis: { recurly: { subdomain: 'sl', @@ -120,8 +131,14 @@ describe('SubscriptionController', function () { getUser: sinon.stub().resolves(this.user), }, } + this.SplitTestV2Hander = { + promises: { + getAssignmentForSession: sinon.stub().resolves({ variant: 'default' }), + }, + } this.SubscriptionController = SandboxedModule.require(modulePath, { requires: { + '../SplitTests/SplitTestV2Handler': this.SplitTestV2Hander, '../Authentication/SessionManager': this.SessionManager, './SubscriptionHandler': this.SubscriptionHandler, './PlansLocator': this.PlansLocator, @@ -166,6 +183,80 @@ describe('SubscriptionController', function () { currencyCode: this.stubbedCurrencyCode, }) }) + + describe('splitTest', function () { + const cases = [ + { + variant: 'default', + template: 'subscriptions/plans', + }, + { + variant: 'de-ng', + template: 'subscriptions/plans-marketing', + }, + ] + for (const { variant, template } of cases) { + describe(variant, function () { + beforeEach(function () { + const assignment = { variant } + this.SplitTestV2Hander.promises.getAssignmentForSession.resolves( + assignment + ) + }) + it(`should render template ${template}`, function (done) { + this.res.render = page => { + page.should.equal(template) + done() + } + this.SubscriptionController.plansPage(this.req, this.res) + }) + }) + } + }) + + describe('groupPlanModal data', function () { + it('should pass local currency if valid', function (done) { + this.res.render = (page, opts) => { + page.should.equal('subscriptions/plans') + opts.groupPlanModalDefaults.currency.should.equal('GBP') + done() + } + this.GeoIpLookup.promises.getCurrencyCode.resolves({ + currencyCode: 'GBP', + }) + this.SubscriptionController.plansPage(this.req, this.res) + }) + + it('should fallback to USD when valid', function (done) { + this.res.render = (page, opts) => { + page.should.equal('subscriptions/plans') + opts.groupPlanModalDefaults.currency.should.equal('USD') + done() + } + this.GeoIpLookup.promises.getCurrencyCode.resolves({ + currencyCode: 'FOO', + }) + this.SubscriptionController.plansPage(this.req, this.res) + }) + + it('should pass valid options for group plan modal and discard invalid', function (done) { + this.res.render = (page, opts) => { + page.should.equal('subscriptions/plans') + opts.groupPlanModalDefaults.size.should.equal('42') + opts.groupPlanModalDefaults.plan_code.should.equal('collaborator') + opts.groupPlanModalDefaults.currency.should.equal('GBP') + opts.groupPlanModalDefaults.usage.should.equal('foo') + done() + } + this.req.query = { + number: '42', + currency: 'ABC', + plan: 'does-not-exist', + usage: 'foo', + } + this.SubscriptionController.plansPage(this.req, this.res) + }) + }) }) describe('paymentPage', function () {