Просмотр исходного кода

Merge pull request #6599 from overleaf/ta-new-subscription-split-test

Payment Page Split Test

GitOrigin-RevId: bb43cbf4e5722bd18076f2f8bf1014816bce1df0
Timothée Alby 4 лет назад
Родитель
Сommit
0a27b3711f

+ 9 - 1
services/web/app/src/Features/Subscription/SubscriptionController.js

@@ -114,7 +114,15 @@ async function paymentPage(req, res) {
       if (recommendedCurrency && currency == null) {
       if (recommendedCurrency && currency == null) {
         currency = recommendedCurrency
         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',
         title: 'subscribe',
         currency,
         currency,
         countryCode,
         countryCode,

+ 363 - 0
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'])}

+ 2 - 0
services/web/frontend/js/main/new-subscription.js

@@ -167,6 +167,8 @@ export default App.controller(
         $scope.coupon = {
         $scope.coupon = {
           singleUse: pricing.items.coupon.single_use,
           singleUse: pricing.items.coupon.single_use,
           normalPrice: basePrice,
           normalPrice: basePrice,
+          name: pricing.items.coupon.name,
+          normalPriceWithoutTax: basePrice,
         }
         }
         if (
         if (
           pricing.items.coupon.applies_for_months > 0 &&
           pricing.items.coupon.applies_for_months > 0 &&

+ 38 - 0
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;
+}

+ 7 - 0
services/web/locales/en.json

@@ -52,6 +52,7 @@
   "recompile_from_scratch": "Recompile from scratch",
   "recompile_from_scratch": "Recompile from scratch",
   "tagline_free": "Perfect for getting started",
   "tagline_free": "Perfect for getting started",
   "also_provides_free_plan": "__appName__ also provides a free plan -- simply <0>register here</0> to get started.",
   "also_provides_free_plan": "__appName__ also provides a free plan -- simply <0>register here</0> to get started.",
+  "increased_compile_timeout": "Increased compile timeout",
   "compile_timeout": "Compile timeout (minutes)",
   "compile_timeout": "Compile timeout (minutes)",
   "collabs_per_proj_single": "__collabcount__ collaborator per project",
   "collabs_per_proj_single": "__collabcount__ collaborator per project",
   "premium_features": "Premium features",
   "premium_features": "Premium features",
@@ -80,6 +81,7 @@
   "x_price_per_month": "<0>__price__</0> per month",
   "x_price_per_month": "<0>__price__</0> per month",
   "x_price_per_year": "<0>__price__</0> per year",
   "x_price_per_year": "<0>__price__</0> per year",
   "x_price_for_first_month": "<0>__price__</0> for your first month",
   "x_price_for_first_month": "<0>__price__</0> for your first month",
+  "x_price_for_y_months": "<0>__price__</0> for your first __discountMonths__ months",
   "x_price_for_first_year": "<0>__price__</0> for your first year",
   "x_price_for_first_year": "<0>__price__</0> for your first year",
   "x_price_per_month_tax": "<0>__total__</0> (__subtotal__ + __tax__ tax) per month",
   "x_price_per_month_tax": "<0>__total__</0> (__subtotal__ + __tax__ tax) per month",
   "x_price_per_year_tax": "<0>__total__</0> (__subtotal__ + __tax__ tax) per year",
   "x_price_per_year_tax": "<0>__total__</0> (__subtotal__ + __tax__ tax) per year",
@@ -89,6 +91,9 @@
   "normally_x_price_per_year": "Normally __price__ per year",
   "normally_x_price_per_year": "Normally __price__ per year",
   "then_x_price_per_month": "Then __price__ per month",
   "then_x_price_per_month": "Then __price__ per month",
   "then_x_price_per_year": "Then __price__ per year",
   "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",
   "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.",
   "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",
   "template_gallery": "Template Gallery",
@@ -255,6 +260,7 @@
   " to_reactivate_your_subscription_go_to": "To reactivate your subscription go to",
   " to_reactivate_your_subscription_go_to": "To reactivate your subscription go to",
   "subscription_canceled": "Subscription Canceled",
   "subscription_canceled": "Subscription Canceled",
   "coupons_not_included": "This does not include your current discounts, which will be applied automatically before your next payment",
   "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",
   "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.",
   "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.",
   "if_registered_email_sent": "If you have an account, we have sent you an email.",
@@ -1498,6 +1504,7 @@
   "category_operators": "Operators",
   "category_operators": "Operators",
   "category_relations": "Relations",
   "category_relations": "Relations",
   "category_misc": "Misc",
   "category_misc": "Misc",
+  "symboly_palette": "Symbol Palette",
   "no_symbols_found": "No symbols found",
   "no_symbols_found": "No symbols found",
   "learn_more_about_the_symbol_palette": "Learn more about the Symbol Palette and how to use it",
   "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",
   "find_the_symbols_you_need_with_premium": "Find the symbols you need faster with Overleaf Premium",