From 0a27b3711f288d652d1147836cc39af2c056ce8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Alby?= Date: Thu, 10 Feb 2022 10:52:03 +0100 Subject: [PATCH] Merge pull request #6599 from overleaf/ta-new-subscription-split-test Payment Page Split Test GitOrigin-RevId: bb43cbf4e5722bd18076f2f8bf1014816bce1df0 --- .../Subscription/SubscriptionController.js | 10 +- .../app/views/subscriptions/new-updated.pug | 363 ++++++++++++++++++ .../web/frontend/js/main/new-subscription.js | 2 + .../stylesheets/app/subscription.less | 38 ++ services/web/locales/en.json | 7 + 5 files changed, 419 insertions(+), 1 deletion(-) create mode 100644 services/web/app/views/subscriptions/new-updated.pug diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index 07c1fb4865..0cab9b2fa0 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -114,7 +114,15 @@ async function paymentPage(req, res) { if (recommendedCurrency && currency == null) { currency = recommendedCurrency } - res.render('subscriptions/new', { + const assignment = await SplitTestHandler.promises.getAssignment( + req, + 'payment-page' + ) + const template = + assignment && assignment.variant === 'updated-payment-page' + ? 'subscriptions/new-updated' + : 'subscriptions/new' + res.render(template, { title: 'subscribe', currency, countryCode, diff --git a/services/web/app/views/subscriptions/new-updated.pug b/services/web/app/views/subscriptions/new-updated.pug new file mode 100644 index 0000000000..d47bf50784 --- /dev/null +++ b/services/web/app/views/subscriptions/new-updated.pug @@ -0,0 +1,363 @@ +extends ../layout + +block append meta + meta(name="ol-countryCode" content=countryCode) + meta(name="ol-recurlyApiKey" content=settings.apis.recurly.publicKey) + meta(name="ol-recomendedCurrency" content=String(currency).slice(0,3)) + +block head-scripts + script(type="text/javascript", nonce=scriptNonce, src="https://js.recurly.com/v4/recurly.js") + +block content + main.content.content-alt#main-content + .container(ng-controller="NewSubscriptionController" ng-cloak) + .row.card-group + .col-md-3.col-md-push-1 + if showStudentPlan + a.btn-primary.btn.plansPageStudentLink( + href, + ng-click="switchToStudent()" + ) #{translate("special_price_student")} + + .card.card-first + .price-feature-description + h4(ng-if="planName") {{planName}} + h4(ng-if="!planName") #{plan.name} + if plan.features + ul.small + if plan.features.collaborators === 1 + li #{translate("collabs_per_proj_single", {collabcount: 1})} + if plan.features.collaborators === -1 + li #{translate("unlimited_collabs")} + if plan.features.collaborators > 1 + li #{translate("collabs_per_proj", {collabcount: plan.features.collaborators})} + if plan.features.compileTimeout > 1 + li #{translate("increased_compile_timeout")} + if plan.features.dropbox && plan.features.github + li #{translate("sync_dropbox_github")} + if plan.features.versioning + li #{translate("full_doc_history")} + if plan.features.trackChanges + li #{translate("track_changes")} + if plan.features.references + li #{translate("reference_search")} + if plan.features.mendeley || plan.features.zotero + li #{translate("reference_sync")} + if plan.features.symbolyPalette + li #{translate("symboly_palette")} + + + div.price-summary(ng-if="recurlyPrice") + - var priceVars = { price: "{{ availableCurrencies[currencyCode]['symbol'] }}{{ recurlyPrice.total }}"}; + hr + h4 #{translate("payment_summary")} + div.small + .price-summary-line + span + | {{planName}} + span(ng-if="coupon") + | {{ availableCurrencies[currencyCode]['symbol'] }}{{ coupon.normalPriceWithoutTax | number:2 }} + span(ng-if="!coupon") + | {{ availableCurrencies[currencyCode]['symbol'] }}{{ recurlyPrice.subtotal }} + .price-summary-line(ng-if="coupon") + span + | {{ coupon.name }} + span + | –{{ availableCurrencies[currencyCode]['symbol'] }}{{ recurlyPrice.discount}} + + .price-summary-line(ng-if="taxes && taxes[0] && taxes[0].rate > 0") + span + | #{translate("vat")} {{taxes[0].rate * 100}}% + span + | {{ availableCurrencies[currencyCode]['symbol'] }}{{ recurlyPrice.tax }} + .price-summary-line.price-summary-total-line + span + b {{ monthlyBilling ? '#{translate("total_per_month")}' : '#{translate("total_per_year")}'}} + span + b {{ availableCurrencies[currencyCode]['symbol'] }}{{ recurlyPrice.total }} + + div.small.price-details-spacing(ng-if="trialLength || coupon") + div.small(ng-if="trialLength") !{translate("first_few_days_free", {trialLen:'{{trialLength}}'})} + + div.small(ng-if="recurlyPrice") + - var priceVars = { price: "{{ availableCurrencies[currencyCode]['symbol'] }}{{ recurlyPrice.total }}", discountMonths: "{{ coupon.discountMonths }}" }; + span(ng-if="!coupon.singleUse && coupon.discountMonths > 0 && monthlyBilling") + | !{translate("x_price_for_y_months", priceVars, ['strong'] )} + span(ng-if="coupon.singleUse && monthlyBilling") + | !{translate("x_price_for_first_month", priceVars, ['strong'] )} + span(ng-if="coupon.singleUse && !monthlyBilling") + | !{translate("x_price_for_first_year", priceVars, ['strong'] )} + + div.small(ng-if="coupon && coupon.normalPrice") + - var noDiscountPriceAngularExp = "{{ availableCurrencies[currencyCode]['symbol']}}{{coupon.normalPrice | number:2 }}"; + span(ng-if="!coupon.singleUse && coupon.discountMonths > 0 && monthlyBilling") + | !{translate("then_x_price_per_month", { price: noDiscountPriceAngularExp } )} + span(ng-if="!coupon.singleUse && !coupon.discountMonths && monthlyBilling") + | !{translate("normally_x_price_per_month", { price: noDiscountPriceAngularExp } )} + span(ng-if="!coupon.singleUse && !monthlyBilling") + | !{translate("normally_x_price_per_year", { price: noDiscountPriceAngularExp } )} + span(ng-if="coupon.singleUse && monthlyBilling") + | !{translate("then_x_price_per_month", { price: noDiscountPriceAngularExp } )} + span(ng-if="coupon.singleUse && !monthlyBilling") + | !{translate("then_x_price_per_year", { price: noDiscountPriceAngularExp } )} + hr + + p.price-cancel-anytime.text-center(ng-non-bindable) !{translate("cancel_anytime", { appName:'{{settings.appName}}' })} + + .col-md-5.col-md-push-1 + .card.card-highlighted.card-border(ng-hide="threeDSecureFlow") + .alert.alert-danger(ng-show="recurlyLoadError") + strong #{translate('payment_provider_unreachable_error')} + .price-switch-header(ng-hide="recurlyLoadError") + .row + .col-xs-9 + h2 {{planName}} + .col-xs-3 + div.dropdown.changePlanButton.pull-right(ng-cloak, dropdown) + a.btn.btn-default.dropdown-toggle( + href="#", + data-toggle="dropdown", + dropdown-toggle + ) + | {{currencyCode}} ({{allCurrencies[currencyCode]['symbol']}}) + span.caret + ul.dropdown-menu(role="menu") + li(ng-repeat="(currency, value) in availableCurrencies") + a( + ng-click="changeCurrency(currency)", + ) {{currency}} ({{value['symbol']}}) + .row(ng-if="planCode == 'student-annual' || planCode == 'student-monthly' || planCode == 'student_free_trial_7_days'") + .col-xs-12 + p.student-disclaimer #{translate('student_disclaimer')} + + .row(ng-hide="recurlyLoadError") + div() + .col-md-12() + form( + name="simpleCCForm" + novalidate + ) + + div.payment-method-toggle + a.payment-method-toggle-switch( + href + ng-click="setPaymentMethod('credit_card');" + ng-class="paymentMethod.value === 'credit_card' ? 'payment-method-toggle-switch-selected' : ''" + ) + i.fa.fa-cc-mastercard.fa-2x(aria-hidden="true") + span   + i.fa.fa-cc-visa.fa-2x(aria-hidden="true") + span   + i.fa.fa-cc-amex.fa-2x(aria-hidden="true") + span.sr-only Pay with Mastercard, Visa, or Amex + a.payment-method-toggle-switch( + href + ng-click="setPaymentMethod('paypal');" + ng-class="paymentMethod.value === 'paypal' ? 'payment-method-toggle-switch-selected' : ''" + ) + i.fa.fa-cc-paypal.fa-2x(aria-hidden="true") + span.sr-only Pay with PayPal + + .alert.alert-warning.small(ng-show="genericError") + strong {{genericError}} + + .alert.alert-warning.small(ng-show="couponError") + strong {{couponError}} + + div(ng-show="paymentMethod.value === 'credit_card'") + .row + .col-xs-6 + .form-group(ng-class="validation.errorFields.first_name || inputHasError(simpleCCForm.firstName) ? 'has-error' : ''") + label(for="first-name") #{translate('first_name')} + input#first-name.form-control( + type="text" + maxlength='255' + data-recurly="first_name" + name="firstName" + ng-model="data.first_name" + required + ) + span.input-feedback-message(ng-if="simpleCCForm.firstName.$error.required") #{translate('this_field_is_required')} + .col-xs-6 + .form-group(ng-class="validation.errorFields.last_name || inputHasError(simpleCCForm.lastName)? 'has-error' : ''") + label(for="last-name") #{translate('last_name')} + input#last-name.form-control( + type="text" + maxlength='255' + data-recurly="last_name" + name="lastName" + ng-model="data.last_name" + required + ) + span.input-feedback-message(ng-if="simpleCCForm.lastName.$error.required") #{translate('this_field_is_required')} + + .form-group(ng-class="validation.errorFields.number ? 'has-error' : ''") + label(for="card-no") #{translate("credit_card_number")} + div#card-no( + type="text" + name="ccNumber" + data-recurly='number' + ) + + .row + .col-xs-3 + .form-group.has-feedback(ng-class="validation.errorFields.month ? 'has-error' : ''") + label(for="month").capitalised #{translate("month")} + div( + type="number" + name="month" + data-recurly="month" + ) + .col-xs-3 + .form-group.has-feedback(ng-class="validation.errorFields.year ? 'has-error' : ''") + label(for="year").capitalised #{translate("year")} + div( + type="number" + name="year" + data-recurly="year" + ) + + .col-xs-6 + .form-group.has-feedback(ng-class="validation.errorFields.cvv ? 'has-error' : ''") + label #{translate("security_code")} + div( + type="number" + ng-model="data.cvv" + data-recurly="cvv" + name="cvv" + cc-format-sec-code + ) + .form-control-feedback + a.form-helper( + href + tabindex="-1" + tooltip-template="'cvv-tooltip-tpl.html'" + tooltip-trigger="mouseenter" + tooltip-append-to-body="true" + ) ? + + div + .row + .col-xs-12 + .form-group(ng-class="validation.errorFields.address1 || inputHasError(simpleCCForm.address1) ? 'has-error' : ''") + label(for="address-line-1") #{translate('address_line_1')} + input#address-line-1.form-control( + type="text" + maxlength="255" + data-recurly="address1" + name="address1" + ng-model="data.address1" + required + ) + span.input-feedback-message(ng-if="simpleCCForm.address1.$error.required") #{translate('this_field_is_required')} + + .row + .col-xs-12 + .form-group.has-feedback(ng-class="validation.errorFields.address2 ? 'has-error' : ''") + label(for="address-line-2") #{translate('address_line_2')} + input#address-line-2.form-control( + type="text" + maxlength="255" + data-recurly="address2" + name="address2" + ng-model="data.address2" + ) + + .row + .col-xs-4 + .form-group(ng-class="validation.errorFields.postal_code || inputHasError(simpleCCForm.postalCode) ? 'has-error' : ''") + label(for="postal-code") #{translate('postal_code')} + input#postal-code.form-control( + type="text" + maxlength="255" + data-recurly="postal_code" + name="postalCode" + ng-model="data.postal_code" + required + ) + span.input-feedback-message(ng-if="simpleCCForm.postalCode.$error.required") #{translate('this_field_is_required')} + + .col-xs-8 + .form-group(ng-class="validation.errorFields.country || inputHasError(simpleCCForm.country) ? 'has-error' : ''") + label(for="country") #{translate('country')} + select#country.form-control( + data-recurly="country" + ng-model="data.country" + name="country" + ng-change="updateCountry()" + ng-selected="{{country.code == data.country}}" + ng-model-options="{ debounce: 200 }" + required + ) + option(value='', disabled) #{translate("country")} + option(value='-', disabled) -------------- + option(ng-repeat="country in countries" ng-bind-html="country.name" value="{{country.code}}") + span.input-feedback-message(ng-if="simpleCCForm.country.$error.required") #{translate('this_field_is_required')} + + .form-group + .checkbox + label + input( + type="checkbox" + ng-model="ui.addCompanyDetails" + ) + | + | #{translate("add_company_details")} + + .form-group(ng-show="ui.addCompanyDetails") + label(for="company-name") #{translate("company_name")} + input#company-name.form-control( + type="text" + name="companyName" + ng-model="data.company" + ) + + .form-group(ng-show="ui.addCompanyDetails && taxes.length") + label(for="vat-number") #{translate("vat_number")} + input#vat-number.form-control( + type="text" + name="vatNumber" + ng-model="data.vat_number" + ng-blur="applyVatNumber()" + ) + + if (showCouponField) + .form-group + label(for="coupon-code") #{translate('coupon_code')} + input#coupon-code.form-control( + type="text" + ng-blur="applyCoupon()" + ng-model="data.coupon" + ) + + p(ng-if="paymentMethod.value === 'paypal'") #{translate("paypal_upgrade")} + + div.payment-submit + button.btn.btn-success.btn-block( + ng-click="submit()" + ng-disabled="processing || !isFormValid(simpleCCForm);" + ) + span(ng-show="processing") + i.fa.fa-spinner.fa-spin(aria-hidden="true") + span.sr-only #{translate('processing')} + |   + span(ng-if="paymentMethod.value === 'credit_card'") + | {{ monthlyBilling ? '#{translate("upgrade_cc_btn")}' : '#{translate("upgrade_now")}'}} + span(ng-if="paymentMethod.value !== 'credit_card'") #{translate("upgrade_paypal_btn")} + + p.tos-agreement-notice !{translate("by_subscribing_you_agree_to_our_terms_of_service", {}, [{name: 'a', attrs: {href: '/legal#Terms', target:'_blank', rel:'noopener noreferrer'}}])} + + div.three-d-secure-container.card.card-highlighted.card-border(ng-show="threeDSecureFlow") + .alert.alert-info.small(aria-live="assertive") + strong #{translate('card_must_be_authenticated_by_3dsecure')} + div.three-d-secure-recurly-container + + script(type="text/javascript", nonce=scriptNonce). + ga('send', 'event', 'pageview', 'payment_form', "#{plan_code}") + + script( + type="text/ng-template" + id="cvv-tooltip-tpl.html" + ) + p !{translate("for_visa_mastercard_and_discover", {}, ['strong', 'strong', 'strong'])} + p !{translate("for_american_express", {}, ['strong', 'strong', 'strong'])} diff --git a/services/web/frontend/js/main/new-subscription.js b/services/web/frontend/js/main/new-subscription.js index 2fe2c66bb1..e1873817b6 100644 --- a/services/web/frontend/js/main/new-subscription.js +++ b/services/web/frontend/js/main/new-subscription.js @@ -167,6 +167,8 @@ export default App.controller( $scope.coupon = { singleUse: pricing.items.coupon.single_use, normalPrice: basePrice, + name: pricing.items.coupon.name, + normalPriceWithoutTax: basePrice, } if ( pricing.items.coupon.applies_for_months > 0 && diff --git a/services/web/frontend/stylesheets/app/subscription.less b/services/web/frontend/stylesheets/app/subscription.less index 5c392597f8..555c058f68 100644 --- a/services/web/frontend/stylesheets/app/subscription.less +++ b/services/web/frontend/stylesheets/app/subscription.less @@ -90,3 +90,41 @@ } } } + +.price-switch-header { + margin-bottom: @line-height-computed; + h2 { + margin: 0; + } +} + +.price-feature-description { + h3 { + margin-top: 0; + } + ul { + padding-left: 10px; + } + li { + list-style-position: inside; + } +} + +.price-summary { + .price-summary-line { + display: flex; + justify-content: space-between; + } + .price-summary-total-line { + margin-top: 5px; + font-size: 16px; + } +} + +.price-details-spacing { + height: @line-height-computed / 2; +} + +.price-cancel-anytime { + font-size: 12px; +} diff --git a/services/web/locales/en.json b/services/web/locales/en.json index dee02ad96b..efc1d42496 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -52,6 +52,7 @@ "recompile_from_scratch": "Recompile from scratch", "tagline_free": "Perfect for getting started", "also_provides_free_plan": "__appName__ also provides a free plan -- simply <0>register here to get started.", + "increased_compile_timeout": "Increased compile timeout", "compile_timeout": "Compile timeout (minutes)", "collabs_per_proj_single": "__collabcount__ collaborator per project", "premium_features": "Premium features", @@ -80,6 +81,7 @@ "x_price_per_month": "<0>__price__ per month", "x_price_per_year": "<0>__price__ per year", "x_price_for_first_month": "<0>__price__ for your first month", + "x_price_for_y_months": "<0>__price__ for your first __discountMonths__ months", "x_price_for_first_year": "<0>__price__ for your first year", "x_price_per_month_tax": "<0>__total__ (__subtotal__ + __tax__ tax) per month", "x_price_per_year_tax": "<0>__total__ (__subtotal__ + __tax__ tax) per year", @@ -89,6 +91,9 @@ "normally_x_price_per_year": "Normally __price__ per year", "then_x_price_per_month": "Then __price__ per month", "then_x_price_per_year": "Then __price__ per year", + "total_per_year": "Total per year", + "total_per_month": "Total per month", + "vat": "VAT", "for_your_first": "for your first", "sso_not_linked": "You have not linked your account to __provider__. Please log in to your account another way and link your __provider__ account via your account settings.", "template_gallery": "Template Gallery", @@ -255,6 +260,7 @@ " to_reactivate_your_subscription_go_to": "To reactivate your subscription go to", "subscription_canceled": "Subscription Canceled", "coupons_not_included": "This does not include your current discounts, which will be applied automatically before your next payment", + "payment_summary": "Payment summary", "email_already_registered_secondary": "This email is already registered as a secondary email", "secondary_email_password_reset": "That email is registered as a secondary email. Please enter the primary email for your account.", "if_registered_email_sent": "If you have an account, we have sent you an email.", @@ -1498,6 +1504,7 @@ "category_operators": "Operators", "category_relations": "Relations", "category_misc": "Misc", + "symboly_palette": "Symbol Palette", "no_symbols_found": "No symbols found", "learn_more_about_the_symbol_palette": "Learn more about the Symbol Palette and how to use it", "find_the_symbols_you_need_with_premium": "Find the symbols you need faster with Overleaf Premium",