diff --git a/services/web/app/views/subscriptions/new.jade b/services/web/app/views/subscriptions/new.jade index e84dc56eec..75d806eb7a 100644 --- a/services/web/app/views/subscriptions/new.jade +++ b/services/web/app/views/subscriptions/new.jade @@ -51,226 +51,341 @@ block content div(ng-if="normalPrice") span.small Normally {{price.currency.symbol}}{{normalPrice}} .row - .col-md-12 - form(ng-show="planName") - - - .row - .col-md-12 - .form-group - .row - .col-md-6 - label.radio-inline - input.paymentTypeOption(type="radio",value="credit_card", ng-model="paymentMethod") - i.fa.fa-cc-mastercard.fa-3x - span   - i.fa.fa-cc-visa.fa-3x - .col-md-6 - label.radio-inline - input.paymentTypeOption(type="radio", value="paypal", ng-model="paymentMethod") - i.fa.fa-cc-paypal.fa-3x - - - - .alert.alert-warning.small(ng-show="genericError") - strong {{genericError}} - - span(ng-hide="paymentMethod == 'paypal'") + div(sixpack-switch="subscription-form") + .col-md-12(sixpack-default) + form(ng-show="planName",novalidate) .row .col-md-12 .form-group - div.alert.alert-warning.small(ng-hide="validation.correctCvv") #{translate("invalid")} CVV - div.alert.alert-warning.small(ng-hide="validation.correctCardNumber") #{translate("invalid")} #{translate("credit_card_number")} + .row + .col-md-6 + label.radio-inline + input.paymentTypeOption(type="radio",value="credit_card", ng-model="paymentMethod") + i.fa.fa-cc-mastercard.fa-3x + span   + i.fa.fa-cc-visa.fa-3x + .col-md-6 + label.radio-inline + input.paymentTypeOption(type="radio", value="paypal", ng-model="paymentMethod") + i.fa.fa-cc-paypal.fa-3x + + .alert.alert-warning.small(ng-show="genericError") + strong {{genericError}} + + span(ng-hide="paymentMethod == 'paypal'") + .row + .col-md-12 + .form-group + div.alert.alert-warning.small(ng-hide="validation.correctCvv") #{translate("invalid")} CVV + div.alert.alert-warning.small(ng-hide="validation.correctCardNumber") #{translate("invalid")} #{translate("credit_card_number")} + .row + .col-md-6 + .form-group(ng-class="validation.number == false || validation.errorFields.number ? 'has-error' : ''") + input.form-control(ng-model='data.number', ng-blur="validateCardNumber()", placeholder="#{translate('credit_card_number')}") + .col-md-3 + .form-group(ng-class="validation.correctCvv == false || validation.errorFields.cvv ? 'has-error' : ''") + input.form-control(ng-model='data.cvv', ng-blur="validateCvv()", placeholder="CVV") + .row + .col-md-12 + div.alert.alert-warning.small(ng-hide="validation.correctExpiry") #{translate("invalid")} #{translate("expiry")} + .row + .col-md-3 + .form-group(ng-class="validation.correctExpiry == false || validation.errorFields.month ? 'has-error' : ''") + select.form-control(data-recurly='month', ng-change="validateExpiry()", ng-model='data.month') + option(value="", disabled, selected) Month + option(value="01") 01 + option(value="02") 02 + option(value="03") 03 + option(value="04") 04 + option(value="05") 05 + option(value="06") 06 + option(value="07") 07 + option(value="08") 08 + option(value="09") 09 + option(value="10") 10 + option(value="11") 11 + option(value="12") 12 + .col-md-4 + .form-group(ng-class="validation.correctExpiry == false || validation.errorFields.year ? 'has-error' : ''") + select.form-control(data-recurly='year', ng-change="validateExpiry()", ng-model='data.year') + option(value="", disabled, selected) Year + option(value="2016") 2016 + option(value="2017") 2017 + option(value="2018") 2018 + option(value="2019") 2019 + option(value="2020") 2020 + option(value="2021") 2021 + option(value="2022") 2022 + option(value="2023") 2023 + option(value="2024") 2024 + option(value="2025") 2025 + option(value="2026") 2026 .row .col-md-6 - .form-group(ng-class="validation.number == false || validation.errorFields.number ? 'has-error' : ''") - input.form-control(ng-model='data.number', ng-blur="validateCardNumber()", placeholder="#{translate('credit_card_number')}") - .col-md-3 - .form-group(ng-class="validation.correctCvv == false || validation.errorFields.cvv ? 'has-error' : ''") - input.form-control(ng-model='data.cvv', ng-blur="validateCvv()", placeholder="CVV") + .form-group(ng-class="validation.errorFields.first_name ? 'has-error' : ''") + input.form-control(type='text', value='', maxlength='255', , onkeyup='', data-recurly="first_name", ng-model="data.first_name", required, placeholder="#{translate('first_name')}") + .col-md-6 + .form-group(ng-class="validation.errorFields.last_name ? 'has-error' : ''") + input.form-control(type='text', value='', maxlength='255', onkeyup='', data-recurly="last_name", ng-model="data.last_name", required, placeholder="#{translate('last_name')}") + hr .row .col-md-12 - div.alert.alert-warning.small(ng-hide="validation.correctExpiry") #{translate("invalid")} #{translate("expiry")} + .form-group(ng-class="validation.errorFields.address1 ? 'has-error' : ''") + label #{translate("billing_address")} + input.form-control(type='text', value='', maxlength='255', onkeyup='', ng-model="data.address1", placeholder="#{translate('address')}") + .form-group(ng-class="validation.errorFields.address2 ? 'has-error' : ''") + input.form-control(type='text', value='', maxlength='255', onkeyup='', ng-model="data.address2", placeholder="#{translate('address')}") + .form-group(ng-class="validation.errorFields.state ? 'has-error' : ''") + input.form-control(type='text', value='', maxlength='255', onkeyup='', ng-model="data.state", placeholder="#{translate('state')}") .row - .col-md-3 - .form-group(ng-class="validation.correctExpiry == false || validation.errorFields.month ? 'has-error' : ''") - select.form-control(data-recurly='month', ng-change="validateExpiry()", ng-model='data.month') - option(value="", disabled, selected) Month - option(value="01") 01 - option(value="02") 02 - option(value="03") 03 - option(value="04") 04 - option(value="05") 05 - option(value="06") 06 - option(value="07") 07 - option(value="08") 08 - option(value="09") 09 - option(value="10") 10 - option(value="11") 11 - option(value="12") 12 - .col-md-4 - .form-group(ng-class="validation.correctExpiry == false || validation.errorFields.year ? 'has-error' : ''") - select.form-control(data-recurly='year', ng-change="validateExpiry()", ng-model='data.year') - option(value="", disabled, selected) Year - option(value="2016") 2016 - option(value="2017") 2017 - option(value="2018") 2018 - option(value="2019") 2019 - option(value="2020") 2020 - option(value="2021") 2021 - option(value="2022") 2022 - option(value="2023") 2023 - option(value="2024") 2024 - option(value="2025") 2025 - option(value="2026") 2026 - .row - .col-md-6 - .form-group(ng-class="validation.errorFields.first_name ? 'has-error' : ''") - input.form-control(type='text', value='', maxlength='255', , onkeyup='', data-recurly="first_name", ng-model="data.first_name", required, placeholder="#{translate('first_name')}") - .col-md-6 - .form-group(ng-class="validation.errorFields.last_name ? 'has-error' : ''") - input.form-control(type='text', value='', maxlength='255', onkeyup='', data-recurly="last_name", ng-model="data.last_name", required, placeholder="#{translate('last_name')}") - hr - .row - .col-md-12 - .form-group(ng-class="validation.errorFields.address1 ? 'has-error' : ''") - label #{translate("billing_address")} - input.form-control(type='text', value='', maxlength='255', onkeyup='', ng-model="data.address1", placeholder="#{translate('address')}") - .form-group(ng-class="validation.errorFields.address2 ? 'has-error' : ''") - input.form-control(type='text', value='', maxlength='255', onkeyup='', ng-model="data.address2", placeholder="#{translate('address')}") - .form-group(ng-class="validation.errorFields.state ? 'has-error' : ''") - input.form-control(type='text', value='', maxlength='255', onkeyup='', ng-model="data.state", placeholder="#{translate('state')}") - .row - .col-md-7 - .form-group(ng-class="validation.errorFields.city ? 'has-error' : ''") - input.form-control(type='text', value='', maxlength='255', onkeyup='', data-recurly="city", ng-model="data.city", placeholder="#{translate('city')}") - .col-md-5(ng-class="validation.errorFields.postal_code ? 'has-error' : ''") - input.form-control(type='text', value='', maxlength='255', onkeyup='', data-recurly="postal_code", ng-model="data.postal_code", placeholder="#{translate('zip_post_code')}") - .row - .col-md-7 - .form-group(ng-class="validation.errorFields.country ? 'has-error' : ''") - select.form-control(data-recurly="country", ng-model="data.country", ng-change="updateCountry()", required) - mixin countries_options() - .row - .col-md-8 - if (showCouponField) + .col-md-7 + .form-group(ng-class="validation.errorFields.city ? 'has-error' : ''") + input.form-control(type='text', value='', maxlength='255', onkeyup='', data-recurly="city", ng-model="data.city", placeholder="#{translate('city')}") + .col-md-5(ng-class="validation.errorFields.postal_code ? 'has-error' : ''") + input.form-control(type='text', value='', maxlength='255', onkeyup='', data-recurly="postal_code", ng-model="data.postal_code", placeholder="#{translate('zip_post_code')}") + .row + .col-md-7 + .form-group(ng-class="validation.errorFields.country ? 'has-error' : ''") + select.form-control(data-recurly="country", ng-model="data.country", ng-change="updateCountry()", required) + mixin countries_options() + .row + .col-md-8 + if (showCouponField) + .form-group + input.form-control(type='text', ng-blur="applyCoupon()", ng-model="data.coupon", placeholder="#{translate('coupon')}") + .row + .col-md-8 + if (showVatField) + .form-group + input.form-control(type='text', ng-blur="applyVatNumber()", ng-model="data.vat_number", placeholder="#{translate('vat_number')}") + .row + .col-xs-7 .form-group - input.form-control(type='text', ng-blur="applyCoupon()", ng-model="data.coupon", placeholder="#{translate('coupon')}") - .row - .col-md-8 + button.btn.btn-success(ng-click="submit()", ng-disabled="processing", sixpack-convert="payment-left-menu-bottom") #{translate("upgrade_now")} + + .col-xs-3.pricingBreakdown + div(ng-if="price.next.subtotal != price.next.total") Subtotal + div(ng-if="price.next.tax!='0.00'") Tax + div + strong Total + .col-xs-2 + div(ng-if="price.next.subtotal != price.next.total").pull-right {{price.currency.symbol}}{{price.next.subtotal}} + div(ng-if="price.next.tax!='0.00'").pull-right {{price.currency.symbol}}{{price.next.tax}} + div.pull-right + strong {{price.currency.symbol}}{{price.next.total}} + + .col-md-12(sixpack-when="simple") + form( + ng-if="planName" + name="simpleCCForm" + novalidate + ) + div.payment-method-toggle + a.payment-method-toggle-switch( + href + ng-click="setPaymentMethod('credit_card');" + ng-class="paymentMethod === 'credit_card' ? 'payment-method-toggle-switch-selected' : ''" + ) + i.fa.fa-cc-mastercard.fa-2x + span   + i.fa.fa-cc-visa.fa-2x + span   + i.fa.fa-cc-amex.fa-2x + a.payment-method-toggle-switch( + href + ng-click="setPaymentMethod('paypal');" + ng-class="paymentMethod === 'paypal' ? 'payment-method-toggle-switch-selected' : ''" + ) + i.fa.fa-cc-paypal.fa-2x + + .alert.alert-warning.small(ng-show="genericError") + strong {{genericError}} + + div(ng-if="paymentMethod === '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 {{ simpleCCForm.firstName.$error.required ? 'This field is required' : '' }} + .col-xs-6 + .form-group(for="last-name",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 {{ simpleCCForm.lastName.$error.required ? 'This field is required' : '' }} + + .form-group(ng-class="validation.correctCardNumber == false || validation.errorFields.number || inputHasError(simpleCCForm.ccNumber) ? 'has-error' : ''") + label(for="card-no") #{translate("credit_card_number")} + input#card-no.form-control( + type="text" + ng-model="data.number" + name="ccNumber" + ng-focus="validation.correctCardNumber = true; validation.errorFields.number = false;" + ng-blur="validateCardNumber();" + required + cc-format-card-number + ) + span.input-feedback-message {{ simpleCCForm.ccNumber.$error.required ? 'This field is required' : 'Please re-check the card number' }} + + .row + .col-xs-6 + .form-group.has-feedback(ng-class="validation.correctExpiry == false || validation.errorFields.expiry || inputHasError(simpleCCForm.expiry) ? 'has-error' : ''") + label #{translate("expiry")} + input.form-control( + type="text" + ng-model="data.mmYY" + name="expiry" + placeholder="MM / YY" + ng-focus="validation.correctExpiry = true; validation.errorFields.expiry = false;" + ng-blur="updateExpiry(); validateExpiry()" + required + cc-format-expiry + ) + span.input-feedback-message {{ simpleCCForm.expiry.$error.required ? 'This field is required' : 'Please re-check the expiry date' }} + + .col-xs-6 + .form-group.has-feedback(ng-class="validation.correctCvv == false || validation.errorFields.cvv || inputHasError(simpleCCForm.cvv) ? 'has-error' : ''") + label #{translate("security_code")} + input.form-control( + type="text" + ng-model="data.cvv" + ng-focus="validation.correctCvv = true; validation.errorFields.cvv = false;" + ng-blur="validateCvv()" + name="cvv" + required + 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" + ) ? + span.input-feedback-message {{ simpleCCForm.cvv.$error.required ? 'This field is required' : 'Please re-check the security code' }} + + + div + .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()" + required + ) + mixin countries_options() + span.input-feedback-message {{ simpleCCForm.country.$error.required ? 'This field is required' : '' }} + if (showVatField) .form-group - input.form-control(type='text', ng-blur="applyVatNumber()", ng-model="data.vat_number", placeholder="#{translate('vat_number')}") - .row - .col-xs-7 - .form-group - button.btn.btn-success(ng-click="submit()", ng-disabled="processing", sixpack-convert="payment-left-menu-bottom") #{translate("upgrade_now")} - - .col-xs-3.pricingBreakdown - div(ng-if="price.next.subtotal != price.next.total") Subtotal - div(ng-if="price.next.tax!='0.00'") Tax - div - strong Total - .col-xs-2 - div(ng-if="price.next.subtotal != price.next.total").pull-right {{price.currency.symbol}}{{price.next.subtotal}} - div(ng-if="price.next.tax!='0.00'").pull-right {{price.currency.symbol}}{{price.next.tax}} - div.pull-right - strong {{price.currency.symbol}}{{price.next.total}} + label(for="vat-no") #{translate('vat_number')} + input#vat-no.form-control( + type="text" + ng-blur="applyVatNumber()" + ng-model="data.vat_number" + ) + 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 === 'paypal'") #{translate("paypal_upgrade")} + + div.price-breakdown(ng-if="price.next.tax !== '0.00'") + hr.thin + span Total: + strong {{price.currency.symbol}}{{price.next.total}} + span ({{price.currency.symbol}}{{price.next.subtotal}} + {{price.currency.symbol}}{{price.next.tax}} tax) + span(ng-if="monthlyBilling") #{translate("every")} #{translate("month")} + span(ng-if="!monthlyBilling") #{translate("every")} #{translate("year")} + hr.thin - span(sixpack-switch="payment-left-menu-bottom") + div.payment-submit + button.btn.btn-success.btn-block( + ng-click="submit()" + ng-disabled="processing || !isFormValid(simpleCCForm);" + sixpack-convert="payment-left-menu-bottom" + ) + span(ng-show="processing") + i.fa.fa-spinner.fa-spin + |   + | {{ paymentMethod === 'credit_card' ? '#{translate("upgrade_cc_btn")}' : '#{translate("upgrade_paypal_btn")}' }} - .col-md-3.col-md-pull-4(sixpack-default) - if showStudentPlan == 'true' - a.btn-primary.btn.plansPageStudentLink( - href, - ng-click="switchToStudent()" - ) #{translate("half_price_student")} - .card.card-first - .paymentPageFeatures - h3 #{translate("unlimited_projects")} - p #{translate("create_unlimited_projects")} + .col-md-3.col-md-pull-4 + if showStudentPlan == 'true' + a.btn-primary.btn.plansPageStudentLink( + href, + ng-click="switchToStudent()" + ) #{translate("half_price_student")} + + .card.card-first + .paymentPageFeatures + h3 #{translate("unlimited_projects")} + p #{translate("create_unlimited_projects")} + + h3 + if plan.features.collaborators == -1 + - var collaboratorCount = 'Unlimited' + else + - var collaboratorCount = plan.features.collaborators + | #{translate("collabs_per_proj", {collabcount:collaboratorCount})} + p #{translate("work_on_single_version")}. #{translate("view_collab_edits")} in real time. - h3 - if plan.features.collaborators == -1 - - var collaboratorCount = 'Unlimited' - else - - var collaboratorCount = plan.features.collaborators - | #{translate("collabs_per_proj", {collabcount:collaboratorCount})} - p #{translate("work_on_single_version")}. #{translate("view_collab_edits")} in real time. - - h3 #{translate("full_doc_history")} - p #{translate("see_what_has_been")} - span.added #{translate("added")} - | #{translate("and")} - span.removed #{translate("removed")}. - | #{translate("restore_to_any_older_version")}. - - h3 #{translate("sync_to_dropbox")} - p - | #{translate("acces_work_from_anywhere")}. - | #{translate("work_offline_and_sync_with_dropbox")}. - - hr - - p.small.text-center We're confident that you'll love ShareLaTeX, but if not you can cancel anytime. We'll give you your money back, no questions asked, if you let us know within 30 days. - hr - span                   - a(href="https://www.positivessl.com" style="font-family: arial; font-size: 10px; color: #212121; text-decoration: none;") - img(src="https://www.positivessl.com/images-new/PositiveSSL_tl_trans.png" alt="SSL Certificate" title="SSL Certificate" border="0") - div(style="font-family: arial;font-weight:bold;font-size:15px;color:#86BEE0;") - a(href="https://www.positivessl.com" style="color:#86BEE0; text-decoration: none;") - .col-md-3.col-md-pull-4(sixpack-when="bolder") - if showStudentPlan == 'true' - a.btn-primary.btn.plansPageStudentLink( - href, - ng-click="switchToStudent()" - ) #{translate("half_price_student")} - - .card.card-first - .paymentPageFeatures - .page-header - h2 #{translate("features")} - h3 - i.fa.fa-check   - | #{translate("unlimited_projects")} + h3 #{translate("full_doc_history")} + p #{translate("see_what_has_been")} + span.added #{translate("added")} + | #{translate("and")} + span.removed #{translate("removed")}. + | #{translate("restore_to_any_older_version")}. - h3 - i.fa.fa-check   - if plan.features.collaborators == -1 - - var collaboratorCount = 'Unlimited' - else - - var collaboratorCount = plan.features.collaborators - | #{translate("collabs_per_proj", {collabcount:collaboratorCount})} + h3 #{translate("sync_to_dropbox")} + p + | #{translate("acces_work_from_anywhere")}. + | #{translate("work_offline_and_sync_with_dropbox")}. - h3 - i.fa.fa-check   - | #{translate("full_doc_history")} + hr - h3 - i.fa.fa-check   - | #{translate("sync_to_dropbox")} - - h3 - i.fa.fa-check   - | #{translate("sync_to_github")} - h3 - i.fa.fa-check   - | #{translate("Compile Larger Projects")} - hr - - h2.text-center 30 Day Guarantee - hr - span                   - a(href="https://www.positivessl.com" style="font-family: arial; font-size: 10px; color: #212121; text-decoration: none;") - img(src="https://www.positivessl.com/images-new/PositiveSSL_tl_trans.png" alt="SSL Certificate" title="SSL Certificate" border="0") - div(style="font-family: arial;font-weight:bold;font-size:15px;color:#86BEE0;") - a(href="https://www.positivessl.com" style="color:#86BEE0; text-decoration: none;") + p.small.text-center We're confident that you'll love ShareLaTeX, but if not you can cancel anytime. We'll give you your money back, no questions asked, if you let us know within 30 days. + hr + span                   + a(href="https://www.positivessl.com" style="font-family: arial; font-size: 10px; color: #212121; text-decoration: none;") + img(src="https://www.positivessl.com/images-new/PositiveSSL_tl_trans.png" alt="SSL Certificate" title="SSL Certificate" border="0") + div(style="font-family: arial;font-weight:bold;font-size:15px;color:#86BEE0;") + a(href="https://www.positivessl.com" style="color:#86BEE0; text-decoration: none;") script(type="text/javascript"). ga('send', 'event', 'pageview', 'payment_form', "#{plan_code}") + script( + type="text/ng-template" + id="cvv-tooltip-tpl.html" + ) + p For #[strong Visa, MasterCard and Discover], the #[strong 3 digits] on the #[strong back] of your card. + p For #[strong American Express], the #[strong 4 digits] on the #[strong front] of your card. + mixin countries_options() option(value='', disabled, selected) #{translate("country")} option(value='-') -------------- diff --git a/services/web/public/coffee/directives/creditCards.coffee b/services/web/public/coffee/directives/creditCards.coffee new file mode 100644 index 0000000000..3ee89a610a --- /dev/null +++ b/services/web/public/coffee/directives/creditCards.coffee @@ -0,0 +1,539 @@ +define [ + "base" +], (App) -> + App.factory 'ccUtils', () -> + defaultFormat = /(\d{1,4})/g; + defaultInputFormat = /(?:^|\s)(\d{4})$/ + + cards = [ + # Credit cards + { + type: 'visa' + patterns: [4] + format: defaultFormat + length: [13, 16] + cvcLength: [3] + luhn: true + } + { + type: 'mastercard' + patterns: [ + 51, 52, 53, 54, 55, + 22, 23, 24, 25, 26, 27 + ] + format: defaultFormat + length: [16] + cvcLength: [3] + luhn: true + } + { + type: 'amex' + patterns: [34, 37] + format: /(\d{1,4})(\d{1,6})?(\d{1,5})?/ + length: [15] + cvcLength: [3..4] + luhn: true + } + { + type: 'dinersclub' + patterns: [30, 36, 38, 39] + format: /(\d{1,4})(\d{1,6})?(\d{1,4})?/ + length: [14] + cvcLength: [3] + luhn: true + } + { + type: 'discover' + patterns: [60, 64, 65, 622] + format: defaultFormat + length: [16] + cvcLength: [3] + luhn: true + } + { + type: 'unionpay' + patterns: [62, 88] + format: defaultFormat + length: [16..19] + cvcLength: [3] + luhn: false + } + { + type: 'jcb' + patterns: [35] + format: defaultFormat + length: [16] + cvcLength: [3] + luhn: true + } + ] + + cardFromNumber = (num) -> + num = (num + '').replace(/\D/g, "") + for card in cards + for pattern in card.patterns + p = pattern + "" + return card if num.substr(0, p.length) == p + + cardFromType = (type) -> + return card for card in cards when card.type is type + + cardType = (num) -> + return null unless num + cardFromNumber(num)?.type or null + + formatCardNumber = (num) -> + num = num.replace(/\D/g, '') + card = cardFromNumber(num) + return num unless card + + upperLength = card.length[card.length.length - 1] + num = num[0...upperLength] + + if card.format.global + num.match(card.format)?.join(' ') + else + groups = card.format.exec(num) + return unless groups? + groups.shift() + groups = $.grep(groups, (n) -> n) # Filter empty groups + groups.join(' ') + + formatExpiry = (expiry) -> + parts = expiry.match(/^\D*(\d{1,2})(\D+)?(\d{1,4})?/) + return '' unless parts + + mon = parts[1] || '' + sep = parts[2] || '' + year = parts[3] || '' + + if year.length > 0 + sep = ' / ' + + else if sep is ' /' + mon = mon.substring(0, 1) + sep = '' + + else if mon.length == 2 or sep.length > 0 + sep = ' / ' + + else if mon.length == 1 and mon not in ['0', '1'] + mon = "0#{mon}" + sep = ' / ' + + return mon + sep + year + + parseExpiry = (value = "") -> + [month, year] = value.split(/[\s\/]+/, 2) + + # Allow for year shortcut + if year?.length is 2 and /^\d+$/.test(year) + prefix = (new Date).getFullYear() + prefix = prefix.toString()[0..1] + year = prefix + year + + month = parseInt(month, 10) + year = parseInt(year, 10) + + return unless !isNaN(month) and !isNaN(year) + + month: month, year: year + + return { + fromNumber: cardFromNumber + fromType: cardFromType + cardType: cardType + formatExpiry: formatExpiry + formatCardNumber: formatCardNumber + defaultFormat: defaultFormat + defaultInputFormat: defaultInputFormat + parseExpiry: parseExpiry + } + + App.factory 'ccFormat', (ccUtils, $filter) -> + hasTextSelected = ($target) -> + # If some text is selected + return true if $target.prop('selectionStart')? and + $target.prop('selectionStart') isnt $target.prop('selectionEnd') + + # If some text is selected in IE + if document?.selection?.createRange? + return true if document.selection.createRange().text + + false + + safeVal = (value, $target) -> + try + cursor = $target.prop('selectionStart') + catch error + cursor = null + + last = $target.val() + $target.val(value) + + if cursor != null && $target.is(":focus") + cursor = value.length if cursor is last.length + + # This hack looks for scenarios where we are changing an input's value such + # that "X| " is replaced with " |X" (where "|" is the cursor). In those + # scenarios, we want " X|". + # + # For example: + # 1. Input field has value "4444| " + # 2. User types "1" + # 3. Input field has value "44441| " + # 4. Reformatter changes it to "4444 |1" + # 5. By incrementing the cursor, we make it "4444 1|" + # + # This is awful, and ideally doesn't go here, but given the current design + # of the system there does not appear to be a better solution. + # + # Note that we can't just detect when the cursor-1 is " ", because that + # would incorrectly increment the cursor when backspacing, e.g. pressing + # backspace in this scenario: "4444 1|234 5". + if last != value + prevPair = last[cursor-1..cursor] + currPair = value[cursor-1..cursor] + digit = value[cursor] + cursor = cursor + 1 if /\d/.test(digit) and + prevPair == "#{digit} " and currPair == " #{digit}" + + $target.prop('selectionStart', cursor) + $target.prop('selectionEnd', cursor) + + # Replace Full-Width Chars + replaceFullWidthChars = (str = '') -> + fullWidth = '\uff10\uff11\uff12\uff13\uff14\uff15\uff16\uff17\uff18\uff19' + halfWidth = '0123456789' + + value = '' + chars = str.split('') + + # Avoid using reserved word `char` + for chr in chars + idx = fullWidth.indexOf(chr) + chr = halfWidth[idx] if idx > -1 + value += chr + + value + + # Format Numeric + reFormatNumeric = (e) -> + $target = $(e.currentTarget) + setTimeout -> + value = $target.val() + value = replaceFullWidthChars(value) + value = value.replace(/\D/g, '') + safeVal(value, $target) + + # Format Card Number + reFormatCardNumber = (e) -> + $target = $(e.currentTarget) + setTimeout -> + value = $target.val() + value = replaceFullWidthChars(value) + value = ccUtils.formatCardNumber(value) + safeVal(value, $target) + + formatCardNumber = (e) -> + # Only format if input is a number + digit = String.fromCharCode(e.which) + return unless /^\d+$/.test(digit) + + $target = $(e.currentTarget) + value = $target.val() + card = ccUtils.fromNumber(value + digit) + length = (value.replace(/\D/g, '') + digit).length + + upperLength = 16 + upperLength = card.length[card.length.length - 1] if card + return if length >= upperLength + + # Return if focus isn't at the end of the text + return if $target.prop('selectionStart')? and + $target.prop('selectionStart') isnt value.length + + if card && card.type is 'amex' + # AMEX cards are formatted differently + re = /^(\d{4}|\d{4}\s\d{6})$/ + else + re = /(?:^|\s)(\d{4})$/ + + # If '4242' + 4 + if re.test(value) + e.preventDefault() + setTimeout -> $target.val(value + ' ' + digit) + + # If '424' + 2 + else if re.test(value + digit) + e.preventDefault() + setTimeout -> $target.val(value + digit + ' ') + + formatBackCardNumber = (e) -> + $target = $(e.currentTarget) + value = $target.val() + + # Return unless backspacing + return unless e.which is 8 + + # Return if focus isn't at the end of the text + return if $target.prop('selectionStart')? and + $target.prop('selectionStart') isnt value.length + + # Remove the digit + trailing space + if /\d\s$/.test(value) + e.preventDefault() + setTimeout -> $target.val(value.replace(/\d\s$/, '')) + # Remove digit if ends in space + digit + else if /\s\d?$/.test(value) + e.preventDefault() + setTimeout -> $target.val(value.replace(/\d$/, '')) + + getFormattedCardNumber = (num) -> + num = num.replace(/\D/g, '') + card = ccUtils.fromNumber(num) + return num unless card + + upperLength = card.length[card.length.length - 1] + num = num[0...upperLength] + + if card.format.global + num.match(card.format)?.join(' ') + else + groups = card.format.exec(num) + return unless groups? + groups.shift() + groups = $.grep(groups, (n) -> n) # Filter empty groups + groups.join(' ') + + parseCardNumber = (value) -> + if value? then value.replace(/\s/g, '') else value + + # Format Expiry + reFormatExpiry = (e) -> + $target = $(e.currentTarget) + setTimeout -> + value = $target.val() + value = replaceFullWidthChars(value) + value = ccUtils.formatExpiry(value) + safeVal(value, $target) + + + formatExpiry = (e) -> + # Only format if input is a number + digit = String.fromCharCode(e.which) + return unless /^\d+$/.test(digit) + + $target = $(e.currentTarget) + val = $target.val() + digit + + if /^\d$/.test(val) and val not in ['0', '1'] + e.preventDefault() + setTimeout -> $target.val("0#{val} / ") + + else if /^\d\d$/.test(val) + e.preventDefault() + setTimeout -> + # Split for months where we have the second digit > 2 (past 12) and turn + # that into (m1)(m2) => 0(m1) / (m2) + m1 = parseInt(val[0], 10) + m2 = parseInt(val[1], 10) + if m2 > 2 and m1 != 0 + $target.val("0#{m1} / #{m2}") + else + $target.val("#{val} / ") + + formatForwardExpiry = (e) -> + digit = String.fromCharCode(e.which) + return unless /^\d+$/.test(digit) + + $target = $(e.currentTarget) + val = $target.val() + + if /^\d\d$/.test(val) + $target.val("#{val} / ") + + formatForwardSlash = (e) -> + which = String.fromCharCode(e.which) + return unless which is '/' or which is ' ' + + $target = $(e.currentTarget) + val = $target.val() + + if /^\d$/.test(val) and val isnt '0' + $target.val("0#{val} / ") + + formatBackExpiry = (e) -> + $target = $(e.currentTarget) + value = $target.val() + + # Return unless backspacing + return unless e.which is 8 + + # Return if focus isn't at the end of the text + return if $target.prop('selectionStart')? and + $target.prop('selectionStart') isnt value.length + + # Remove the trailing space + last digit + if /\d\s\/\s$/.test(value) + e.preventDefault() + setTimeout -> $target.val(value.replace(/\d\s\/\s$/, '')) + + parseExpiry = (value) -> + if value? + dateAsObj = ccUtils.parseExpiry(value) + + return unless dateAsObj? + + expiry = new Date dateAsObj.year, dateAsObj.month - 1 + + return $filter('date')(expiry, 'MM/yyyy') + + # Format CVC + reFormatCVC = (e) -> + $target = $(e.currentTarget) + setTimeout -> + value = $target.val() + value = replaceFullWidthChars(value) + value = value.replace(/\D/g, '')[0...4] + safeVal(value, $target) + + # Restrictions + restrictNumeric = (e) -> + # Key event is for a browser shortcut + return true if e.metaKey or e.ctrlKey + + # If keycode is a space + return false if e.which is 32 + + # If keycode is a special char (WebKit) + return true if e.which is 0 + + # If char is a special char (Firefox) + return true if e.which < 33 + + input = String.fromCharCode(e.which) + + # Char is a number or a space + !!/[\d\s]/.test(input) + + restrictCardNumber = (e) -> + $target = $(e.currentTarget) + digit = String.fromCharCode(e.which) + return unless /^\d+$/.test(digit) + + return if hasTextSelected($target) + + # Restrict number of digits + value = ($target.val() + digit).replace(/\D/g, '') + card = ccUtils.fromNumber(value) + + if card + value.length <= card.length[card.length.length - 1] + else + # All other cards are 16 digits long + value.length <= 16 + + restrictExpiry = (e) -> + $target = $(e.currentTarget) + digit = String.fromCharCode(e.which) + return unless /^\d+$/.test(digit) + + return if hasTextSelected($target) + + value = $target.val() + digit + value = value.replace(/\D/g, '') + + return false if value.length > 6 + + restrictCVC = (e) -> + $target = $(e.currentTarget) + digit = String.fromCharCode(e.which) + return unless /^\d+$/.test(digit) + + return if hasTextSelected($target) + + val = $target.val() + digit + val.length <= 4 + + setCardType = (e) -> + $target = $(e.currentTarget) + val = $target.val() + cardType = ccUtils.cardType(val) or 'unknown' + + unless $target.hasClass(cardType) + allTypes = (card.type for card in cards) + + $target.removeClass('unknown') + $target.removeClass(allTypes.join(' ')) + + $target.addClass(cardType) + $target.toggleClass('identified', cardType isnt 'unknown') + $target.trigger('payment.cardType', cardType) + + return { + hasTextSelected + replaceFullWidthChars + reFormatNumeric + reFormatCardNumber + formatCardNumber + formatBackCardNumber + getFormattedCardNumber + parseCardNumber + reFormatExpiry + formatExpiry + formatForwardExpiry + formatForwardSlash + formatBackExpiry + parseExpiry + reFormatCVC + restrictNumeric + restrictCardNumber + restrictExpiry + restrictCVC + setCardType + } + + App.directive "ccFormatExpiry", (ccFormat) -> + restrict: "A" + require: "ngModel" + link: (scope, el, attrs, ngModel) -> + el.on "keypress", ccFormat.restrictNumeric + el.on "keypress", ccFormat.restrictExpiry + el.on "keypress", ccFormat.formatExpiry + el.on "keypress", ccFormat.formatForwardSlash + el.on "keypress", ccFormat.formatForwardExpiry + el.on "keydown", ccFormat.formatBackExpiry + el.on "change", ccFormat.reFormatExpiry + el.on "input", ccFormat.reFormatExpiry + el.on "paste", ccFormat.reFormatExpiry + + ngModel.$parsers.push ccFormat.parseExpiry + ngModel.$formatters.push ccFormat.parseExpiry + + App.directive "ccFormatCardNumber", (ccFormat) -> + restrict: "A" + require: "ngModel" + link: (scope, el, attrs, ngModel) -> + el.on "keypress", ccFormat.restrictNumeric + el.on "keypress", ccFormat.restrictCardNumber + el.on "keypress", ccFormat.formatCardNumber + el.on "keydown", ccFormat.formatBackCardNumber + el.on "paste", ccFormat.reFormatCardNumber + + ngModel.$parsers.push ccFormat.parseCardNumber + ngModel.$formatters.push ccFormat.getFormattedCardNumber + + App.directive "ccFormatSecCode", (ccFormat) -> + restrict: "A" + require: "ngModel" + link: (scope, el, attrs, ngModel) -> + el.on "keypress", ccFormat.restrictNumeric + el.on "keypress", ccFormat.restrictCVC + el.on "paste", ccFormat.reFormatCVC + el.on "change", ccFormat.reFormatCVC + el.on "input", ccFormat.reFormatCVC + + + + \ No newline at end of file diff --git a/services/web/public/coffee/main.coffee b/services/web/public/coffee/main.coffee index d85d89cfe8..c723031016 100644 --- a/services/web/public/coffee/main.coffee +++ b/services/web/public/coffee/main.coffee @@ -26,6 +26,7 @@ define [ "directives/onEnter" "directives/selectAll" "directives/maxHeight" + "directives/creditCards" "services/queued-http" "filters/formatDate" "__MAIN_CLIENTSIDE_INCLUDES__" diff --git a/services/web/public/coffee/main/new-subscription.coffee b/services/web/public/coffee/main/new-subscription.coffee index c153392c15..f5096c06ea 100644 --- a/services/web/public/coffee/main/new-subscription.coffee +++ b/services/web/public/coffee/main/new-subscription.coffee @@ -1,8 +1,9 @@ define [ - "base" + "base", + "directives/creditCards" ], (App)-> - App.controller "NewSubscriptionController", ($scope, MultiCurrencyPricing, abTestManager, $http, sixpack, event_tracking)-> + App.controller "NewSubscriptionController", ($scope, MultiCurrencyPricing, abTestManager, $http, sixpack, event_tracking, ccUtils)-> throw new Error("Recurly API Library Missing.") if typeof recurly is "undefined" $scope.currencyCode = MultiCurrencyPricing.currencyCode @@ -11,6 +12,7 @@ define [ $scope.switchToStudent = ()-> window.location = "/user/subscription/new?planCode=student_free_trial_7_days¤cy=#{$scope.currencyCode}&cc=#{$scope.data.coupon}" + event_tracking.sendMB "subscription-form", { plan : window.plan_code } $scope.paymentMethod = "credit_card" @@ -28,12 +30,12 @@ define [ city:"" country:window.countryCode coupon: window.couponCode + mmYY: "" - $scope.validation = correctCardNumber : true correctExpiry: true - correctCvv:true + correctCvv: true $scope.processing = false @@ -51,12 +53,11 @@ define [ .done() pricing.on "change", => - event_tracking.sendMB "subscription-form", { plan : pricing.items.plan.code } - $scope.planName = pricing.items.plan.name $scope.price = pricing.price $scope.trialLength = pricing.items.plan.trial?.length $scope.monthlyBilling = pricing.items.plan.period.length == 1 + if pricing.items?.coupon?.discount?.type == "percent" basePrice = parseInt(pricing.price.base.plan.unit) $scope.normalPrice = basePrice @@ -74,35 +75,59 @@ define [ $scope.applyVatNumber = -> pricing.tax({tax_code: 'digital', vat_number: $scope.data.vat_number}).done() - $scope.changeCurrency = (newCurrency)-> $scope.currencyCode = newCurrency pricing.currency(newCurrency).done() + $scope.updateExpiry = () -> + parsedDateObj = ccUtils.parseExpiry $scope.data.mmYY + if parsedDateObj? + $scope.data.month = parsedDateObj.month + $scope.data.year = parsedDateObj.year + $scope.validateCardNumber = validateCardNumber = -> + $scope.validation.errorFields = {} if $scope.data.number?.length != 0 $scope.validation.correctCardNumber = recurly.validate.cardNumber($scope.data.number) $scope.validateExpiry = validateExpiry = -> + $scope.validation.errorFields = {} if $scope.data.month?.length != 0 and $scope.data.year?.length != 0 $scope.validation.correctExpiry = recurly.validate.expiry($scope.data.month, $scope.data.year) $scope.validateCvv = validateCvv = -> + $scope.validation.errorFields = {} if $scope.data.cvv?.length != 0 $scope.validation.correctCvv = recurly.validate.cvv($scope.data.cvv) + $scope.inputHasError = inputHasError = (formItem) -> + if !formItem? + return false + + return (formItem.$touched && formItem.$invalid) + + $scope.isFormValid = isFormValid = (form) -> + if $scope.paymentMethod == 'paypal' + return $scope.data.country != "" + else + return (form.$valid and + $scope.validation.correctCardNumber and + $scope.validation.correctExpiry and + $scope.validation.correctCvv) + $scope.updateCountry = -> + console.log $scope.data.country pricing.address({country:$scope.data.country}).done() - $scope.changePaymentMethod = (paymentMethod)-> - if paymentMethod == "paypal" - $scope.usePaypal = true - else - $scope.usePaypal = false + $scope.setPaymentMethod = setPaymentMethod = (method) -> + $scope.paymentMethod = method; + $scope.validation.errorFields = {} + $scope.genericError = "" completeSubscription = (err, recurly_token_id) -> $scope.validation.errorFields = {} if err? + event_tracking.sendMB "subscription-error", err # We may or may not be in a digest loop here depending on # whether recurly could do validation locally, so do it async $scope.$evalAsync () -> @@ -132,6 +157,8 @@ define [ isPaypal : postData.subscriptionDetails.isPaypal } + sixpack.convert "subscription-form" + $http.post("/user/subscription/create", postData) .success (data, status, headers)-> sixpack.convert "in-editor-free-trial-plan", pricing.items.plan.code, (err)-> diff --git a/services/web/public/stylesheets/app/plans.less b/services/web/public/stylesheets/app/plans.less index c375052394..b830db9b23 100644 --- a/services/web/public/stylesheets/app/plans.less +++ b/services/web/public/stylesheets/app/plans.less @@ -89,8 +89,27 @@ .small { font-size: 12px; } + } +.feature { + margin-top: (@line-height-computed / 2); + margin-bottom: (@line-height-computed / 1.5); +} + +.features-check, +.features-copy { + display: inline-block; + width: 12%; + line-height: 1.4; + vertical-align: top; +} + +.features-copy { + width: 88%; +} + + .plansPageStudentLink { margin-left: 20px; margin-top: 20px; diff --git a/services/web/public/stylesheets/app/subscription.less b/services/web/public/stylesheets/app/subscription.less new file mode 100644 index 0000000000..b99fe5d774 --- /dev/null +++ b/services/web/public/stylesheets/app/subscription.less @@ -0,0 +1,74 @@ +.form-helper { + display: inline-block; + width: 1.3em; + height: 1.3em; + line-height: 1.3; + vertical-align: initial; + background-color: @gray; + color: #FFF; + font-weight: bolder; + border-radius: 50%; + + &:hover, + &:focus { + color: #FFF; + text-decoration: none; + } +} + +.price-breakdown { + text-align: center; + margin-bottom: -10px; +} + +.input-feedback-message { + display: none; + font-size: 0.8em; + + .has-error & { + display: inline-block; + } +} + +.payment-submit { + padding-top: (@line-height-computed / 2); +} + +.payment-method-toggle { + margin-bottom: (@line-height-computed / 2); + + &-switch { + display: inline-block; + width: 50%; + text-align: center; + border: solid 1px @gray-lighter; + border-radius: @border-radius-large 0 0 @border-radius-large; + padding: (@line-height-computed / 2); + color: @gray; + + &:hover, + &:focus { + color: @gray; + text-decoration: none; + } + + &:hover { + color: @gray-dark; + } + + & + & { + border-left-width: 0; + border-radius: 0 @border-radius-large @border-radius-large 0; + } + + &-selected { + color: @link-color; + box-shadow: inset 0 -2px 0 0; + + &:hover, + &:focus { + color: @link-color; + } + } + } +} \ No newline at end of file diff --git a/services/web/public/stylesheets/style.less b/services/web/public/stylesheets/style.less index 0cf0939cbf..03df27569b 100755 --- a/services/web/public/stylesheets/style.less +++ b/services/web/public/stylesheets/style.less @@ -75,6 +75,7 @@ @import "app/wiki.less"; @import "app/translations.less"; @import "app/contact-us.less"; +@import "app/subscription.less"; @import "app/sprites.less"; @import "app/invite.less";