Implement checkout page 'refreshed' split test with Recurly Elements (#8320)

* Implement checkout page 'refreshed' split test with Recurly Elements

Co-authored-by: Timothée Alby <timothee.alby@gmail.com>
GitOrigin-RevId: 586a94c7969530f4d2246e9105ef48e868e9510e
This commit is contained in:
Thomas
2022-09-02 11:40:26 +02:00
committed by Copybot
parent 8ba562a943
commit fdc9c4b4b3
6 changed files with 513 additions and 5 deletions

View File

@@ -154,10 +154,25 @@ async function paymentPage(req, res) {
res,
'payment-page'
)
const template =
const useUpdatedPaymentPage =
assignment && assignment.variant === 'updated-payment-page'
? 'subscriptions/new-updated'
: 'subscriptions/new'
const refreshedPaymentPageAssignment =
await SplitTestHandler.promises.getAssignment(
req,
res,
'payment-page-refresh'
)
const useRefreshedPaymentPage =
refreshedPaymentPageAssignment &&
refreshedPaymentPageAssignment.variant === 'refreshed-payment-page'
const template = useRefreshedPaymentPage
? 'subscriptions/new-refreshed'
: useUpdatedPaymentPage
? 'subscriptions/new-updated'
: 'subscriptions/new'
res.render(template, {
title: 'subscribe',
currency,

View File

@@ -0,0 +1,360 @@
extends ../layout
block vars
- var suppressNavbarRight = true
- var suppressFooter = true
block append meta
meta(name="ol-countryCode" content=countryCode)
meta(name="ol-recurlyApiKey" content=settings.apis.recurly.publicKey)
meta(name="ol-recommendedCurrency" 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
.card.card-highlighted
.price-feature-description
h4(ng-if="planName") {{planName}}
h4(ng-if="!planName") #{plan.name}
if plan.features
if plan.features.collaborators === 1
.text-small.number-of-collaborators #{translate("collabs_per_proj_single", {collabcount: 1})}
if plan.features.collaborators === -1
.text-small.number-of-collaborators #{translate("unlimited_collabs")}
if plan.features.collaborators > 1
.text-small.number-of-collaborators #{translate("collabs_per_proj", {collabcount: plan.features.collaborators})}
.text-small #{translate("all_premium_features_including")}
ul.small
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
| &ndash;{{ 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 }}
.change-currency
div.dropdown(ng-cloak dropdown)
button.btn.btn-link.dropdown-toggle.change-currency-toggle(
href="#",
data-toggle="dropdown",
dropdown-toggle
) Change currency
ul.dropdown-menu(role="menu")
li(ng-repeat="(currency, value) in limitedCurrencies")
a(
ng-click="changeCurrency(currency)",
)
span.change-currency-dropdown-selected-icon(ng-show="currency == currencyCode")
i.fa.fa-check
| {{currency}} ({{value['symbol']}})
hr.thin(ng-if="trialLength || coupon")
div.trial-coupon-summary
div(ng-if="trialLength")
- var trialPriceVars = { price: "{{ availableCurrencies[currencyCode]['symbol'] }}{{ recurlyPrice.total }}", trialLen:'{{trialLength}}' };
| !{translate("first_x_days_free_after_that_y_per_month", trialPriceVars, ['strong'] )}
div(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(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.thin
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 #{translate('select_a_payment_method')}
.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
)
.alert.alert-warning.small(ng-show="genericError")
strong {{genericError}}
.alert.alert-warning.small(ng-show="couponError")
strong {{couponError}}
div
.form-group.payment-method-toggle
hr.thin
.radio
.col-xs-8
label
input(
type="radio"
ng-model="paymentMethod.value"
name="payment_method"
checked=true
value="credit_card"
)
strong
| #{translate("card_payment")}
span.hidden-xs
| &nbsp;
i.fa.fa-cc-visa(aria-hidden="true")
| &nbsp;
i.fa.fa-cc-mastercard(aria-hidden="true")
| &nbsp;
i.fa.fa-cc-amex(aria-hidden="true")
.col-xs-4
label
input(
type="radio"
ng-model="paymentMethod.value"
name="payment_method"
checked=false
value="paypal"
)
strong PayPal
span.hidden-xs
| &nbsp;
i.fa.fa-cc-paypal(aria-hidden="true")
div(ng-show="paymentMethod.value === 'credit_card'")
.form-group(ng-class="showCardElementInvalid ? 'has-error' : ''")
label(for="recurly-card-input") #{translate("card_details")}
div#recurly-card-input
span.input-feedback-message(ng-if="showCardElementInvalid") Card details are not valid
.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')}
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')} &nbsp;
i.fa.fa-question-circle(
aria-label=translate('this_address_will_be_shown_on_the_invoice'),
tooltip=translate('this_address_will_be_shown_on_the_invoice'),
tooltip-placement="right",
tooltip-append-to-body="true",
)
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.toggle-address-second-line(ng-hide="ui.showAddressSecondLine")
.col-xs-12
a.text-small(
href="#"
ng-click="showAddressSecondLine($event)"
) + Add another address line
.row(ng-show="ui.showAddressSecondLine")
.col-xs-12
.form-group.has-feedback(ng-class="validation.errorFields.address2 ? 'has-error' : ''")
label(for="address-line-2") #{translate('address_second_line_optional')}
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("proceeding_to_paypal_takes_you_to_the_paypal_site_to_pay")}
hr.thin
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')}
| &nbsp;
span(ng-if="paymentMethod.value === 'credit_card'")
| {{ trialLength ? '#{translate("upgrade_cc_btn")}' : '#{translate("upgrade_now")}'}}
span(ng-if="paymentMethod.value !== 'credit_card'") #{translate("proceed_to_paypal")}
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'])}

View File

@@ -22,11 +22,14 @@ export default App.controller(
}
$scope.ui = {
showCurrencyDropdown: false,
showAddressSecondLine: false,
addCompanyDetails: false,
}
$scope.recurlyLoadError = false
$scope.currencyCode = MultiCurrencyPricing.currencyCode
$scope.initiallySelectedCurrencyCode = MultiCurrencyPricing.currencyCode // track for payment-page-refreshed
$scope.allCurrencies = MultiCurrencyPricing.plans
$scope.availableCurrencies = {}
$scope.planCode = window.plan_code
@@ -157,6 +160,20 @@ export default App.controller(
}
}
// abridged list for payment-page-refreshed split test
$scope.limitedCurrencies = {}
const limitedCurrencyCodes = ['USD', 'EUR', 'GBP']
if (
limitedCurrencyCodes.indexOf($scope.initiallySelectedCurrencyCode) ===
-1
) {
limitedCurrencyCodes.unshift($scope.initiallySelectedCurrencyCode)
}
limitedCurrencyCodes.forEach(currencyCode => {
$scope.limitedCurrencies[currencyCode] =
MultiCurrencyPricing.plans[currencyCode]
})
if (
pricing.items &&
pricing.items.coupon &&
@@ -208,6 +225,41 @@ export default App.controller(
.done()
}
$scope.showAddressSecondLine = function (e) {
e.preventDefault()
$scope.ui.showAddressSecondLine = true
}
$scope.showCurrencyDropdown = function (e) {
e.preventDefault()
$scope.ui.showCurrencyDropdown = true
}
// This check is just so we don't load this on the default checkout variant
const newCardInputElement = document.querySelector('#recurly-card-input')
const elements = recurly.Elements()
if (newCardInputElement) {
const card = elements.CardElement({
displayIcon: true,
style: {
inputType: 'mobileSelect',
fontColor: '#5d6879',
placeholder: {},
invalid: {
fontColor: '#a93529',
},
},
})
card.attach('#recurly-card-input')
card.on('change', state => {
$scope.$applyAsync(() => {
$scope.showCardElementInvalid =
!state.focus && !state.empty && !state.valid
$scope.cardIsValid = state.valid
})
})
}
$scope.applyVatNumber = () =>
pricing
.tax({ tax_code: 'digital', vat_number: $scope.data.vat_number })
@@ -242,7 +294,11 @@ export default App.controller(
if ($scope.paymentMethod.value === 'paypal') {
return $scope.data.country !== ''
} else {
return form.$valid
if (newCardInputElement) {
return form.$valid && $scope.cardIsValid
} else {
return form.$valid
}
}
}
@@ -367,7 +423,11 @@ export default App.controller(
delete tokenData.company
delete tokenData.vat_number
}
recurly.token(tokenData, completeSubscription)
if (newCardInputElement) {
recurly.token(elements, tokenData, completeSubscription)
} else {
recurly.token(tokenData, completeSubscription)
}
}
}

View File

@@ -1,3 +1,20 @@
.recurly-hosted-field {
&:extend(.form-control);
}
.recurly-element-card {
&:extend(.form-control);
padding: 4px 4px;
border: 1px #cccccc solid;
border-radius: 20px;
height: 50px;
}
.recurly-element-card-invalid {
&:extend(.has-error);
border-color: @red;
}
.recurly-element-number {
&:extend(.form-control);
}

View File

@@ -102,15 +102,24 @@
h3 {
margin-top: 0;
}
h4 {
margin-top: 5px;
margin-bottom: 15px;
}
ul {
padding-left: 10px;
}
li {
list-style-position: inside;
}
.number-of-collaborators {
margin-bottom: 5px;
}
}
.price-summary {
padding-bottom: 7.5px;
.price-summary-line {
display: flex;
justify-content: space-between;
@@ -119,6 +128,10 @@
margin-top: 5px;
font-size: 16px;
}
hr {
margin-top: 20px;
margin-bottom: 20px;
}
}
.price-details-spacing {
@@ -128,3 +141,37 @@
.price-cancel-anytime {
font-size: 12px;
}
.trial-coupon-summary {
font-size: 12px;
padding-top: 7.5px;
padding-bottom: 7.5px;
}
.toggle-address-second-line {
margin-bottom: @line-height-computed / 2;
}
.payment-method-toggle {
border-bottom: 1px solid @hr-border;
}
.change-currency {
margin-top: 5px;
}
.change-currency-toggle {
padding-left: 0px;
font-size: 12px;
text-decoration: underline;
color: @text-small-color;
&:hover,
&:focus {
color: @text-small-color;
}
}
.change-currency-dropdown-selected-icon {
position: absolute;
left: 10px;
}

View File

@@ -22,6 +22,8 @@
"github_workflow_files_delete_github_repo": "The repository has been created on GitHub but linking was unsuccessful. You will have to delete GitHub repository or choose a new name.",
"address_line_1": "Address",
"address_line_2": "Address (line 2, optional)",
"address_second_line_optional": "Address second line (optional)",
"this_address_will_be_shown_on_the_invoice": "This address will be shown on the invoice",
"postal_code": "Postal Code",
"reload_editor": "Reload editor",
"compile_error_description": "This project did not compile because of an error",
@@ -92,6 +94,7 @@
"beta_feature_badge": "Beta feature badge",
"invalid_filename": "Upload failed: check that the file name doesnt contain special characters, trailing/leading whitespace or more than __nameLimit__ characters",
"clsi_unavailable": "Sorry, the compile server for your project was temporarily unavailable. Please try again in a few moments.",
"first_x_days_free_after_that_y_per_month": "First <0>__trialLen__ days free</0>, after that <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_for_first_month": "<0>__price__</0> for your first month",
@@ -720,8 +723,10 @@
"your_billing_details_were_saved": "Your billing details were saved",
"security_code": "Security code",
"paypal_upgrade": "To upgrade, click on the button below and log on to PayPal using your email and password.",
"proceeding_to_paypal_takes_you_to_the_paypal_site_to_pay": "Proceeding to PayPal will take you to the PayPal site to pay for your subscription.",
"upgrade_cc_btn": "Upgrade now, pay after 7 days",
"upgrade_paypal_btn": "Continue",
"proceed_to_paypal": "Proceed to PayPal",
"notification_project_invite": "<b>__userName__</b> would like you to join <b>__projectName__</b> <a class=\"btn btn-sm btn-info pull-right\" href=\"/project/__projectId__/invite/token/__token__\">Join Project</a>",
"file_restored": "Your file (__filename__) has been recovered.",
"file_restored_back_to_editor": "You can go back to the editor and work on it again.",
@@ -1006,6 +1011,7 @@
"one_collaborator": "Only one collaborator",
"collaborator": "Collaborator",
"standard": "Standard",
"all_premium_features_including": "All premium features, including:",
"collabs_per_proj": "__collabcount__ collaborators per project",
"full_doc_history": "Full document history",
"sync_to_dropbox": "Sync to Dropbox",
@@ -1398,8 +1404,11 @@
"about_brian_gough": "is a software developer and former theoretical high energy physicist at Fermilab and Los Alamos. For many years he published free software manuals commercially using TeX and LaTeX and was also the maintainer of the GNU Scientific Library.",
"first_few_days_free": "First __trialLen__ days free",
"every": "per",
"select_a_payment_method": "Select a payment method",
"credit_card": "Credit Card",
"card_payment": "Card payment",
"credit_card_number": "Credit Card Number",
"card_details": "Card details",
"invalid": "Invalid",
"expiry": "Expiry Date",
"january": "January",