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 () {