diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index 29d3b4a1b1..882dccc255 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -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, diff --git a/services/web/app/views/subscriptions/new-refreshed.pug b/services/web/app/views/subscriptions/new-refreshed.pug new file mode 100644 index 0000000000..8a55e42ffe --- /dev/null +++ b/services/web/app/views/subscriptions/new-refreshed.pug @@ -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 + | –{{ 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 + |   + i.fa.fa-cc-visa(aria-hidden="true") + |   + i.fa.fa-cc-mastercard(aria-hidden="true") + |   + 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 + |   + 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')}   + 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')} + |   + 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'])} diff --git a/services/web/frontend/js/main/new-subscription.js b/services/web/frontend/js/main/new-subscription.js index e1873817b6..7f68c7890e 100644 --- a/services/web/frontend/js/main/new-subscription.js +++ b/services/web/frontend/js/main/new-subscription.js @@ -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) + } } } diff --git a/services/web/frontend/stylesheets/app/recurly.less b/services/web/frontend/stylesheets/app/recurly.less index abc051e2fa..9f02469a9a 100644 --- a/services/web/frontend/stylesheets/app/recurly.less +++ b/services/web/frontend/stylesheets/app/recurly.less @@ -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); +} diff --git a/services/web/frontend/stylesheets/app/subscription.less b/services/web/frontend/stylesheets/app/subscription.less index 555c058f68..c35968a973 100644 --- a/services/web/frontend/stylesheets/app/subscription.less +++ b/services/web/frontend/stylesheets/app/subscription.less @@ -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; +} diff --git a/services/web/locales/en.json b/services/web/locales/en.json index e1b565bd9d..7bbd288551 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -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 doesn’t 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, after that <0>__price__ per month", "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", @@ -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": "__userName__ would like you to join __projectName__ Join Project", "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",