diff --git a/app/assets/stylesheets/darkswarm/split-checkout.scss b/app/assets/stylesheets/darkswarm/split-checkout.scss index ac535ea8c8..90e70a6a23 100644 --- a/app/assets/stylesheets/darkswarm/split-checkout.scss +++ b/app/assets/stylesheets/darkswarm/split-checkout.scss @@ -97,6 +97,18 @@ } } + .stripe-card { + background: white; + box-sizing: border-box; + font-weight: 400; + padding: 0.6rem 0.5rem; + border: 1px solid #cccccc; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); + border-radius: 0; + height: 42px; + width: 100%; + } + label { margin-bottom: 0.3rem; } @@ -106,23 +118,6 @@ margin: 0; } - span.formError { - background-color: rgba(193, 18, 43, 0.1); - color: $red-700; - font-style: normal; - margin: 0; - font-size: 0.8rem; - display: block; - padding-left: 5px; - padding-right: 5px; - padding-top: 2px; - padding-bottom: 2px; - - &.standalone { - padding: 10px; - } - } - #distributor_address.panel { font-size: 0.875rem; padding: 1rem; @@ -138,6 +133,29 @@ } } + .checkout-input span.formError, div.error.card-errors { + background-color: rgba(193, 18, 43, 0.1); + color: $red-700; + font-style: normal; + margin: 0; + font-size: 0.8rem; + display: block; + padding-left: 5px; + padding-right: 5px; + padding-top: 2px; + padding-bottom: 2px; + + &.standalone { + padding: 10px; + } + } + + div.error.card-errors { + &:empty { + display: none; + } + } + .checkout-submit { margin-top: 5rem; margin-bottom: 5rem; diff --git a/app/controllers/concerns/checkout_callbacks.rb b/app/controllers/concerns/checkout_callbacks.rb index ff8d207add..4c97ee2bd9 100644 --- a/app/controllers/concerns/checkout_callbacks.rb +++ b/app/controllers/concerns/checkout_callbacks.rb @@ -13,7 +13,7 @@ module CheckoutCallbacks prepend_before_action :require_order_cycle prepend_before_action :require_distributor_chosen - before_action :load_order, :associate_user, :load_saved_addresses + before_action :load_order, :associate_user, :load_saved_addresses, :load_saved_credit_cards before_action :load_shipping_methods, :load_countries, if: -> { params[:step] == "details" } before_action :ensure_order_not_completed @@ -41,6 +41,11 @@ module CheckoutCallbacks @order.ship_address ||= finder.ship_address end + def load_saved_credit_cards + @saved_credit_cards = spree_current_user&.credit_cards&.with_payment_profile.to_a + @selected_card = nil + end + def load_shipping_methods @shipping_methods = Spree::ShippingMethod.for_distributor(@order.distributor).order(:name) end diff --git a/app/helpers/checkout_helper.rb b/app/helpers/checkout_helper.rb index c5b22b4784..780c27c595 100644 --- a/app/helpers/checkout_helper.rb +++ b/app/helpers/checkout_helper.rb @@ -143,4 +143,13 @@ module CheckoutHelper def checkout_step?(step) checkout_step == step.to_s end + + def stripe_card_options(cards) + cards.map do |cc| + [ + "#{cc.brand} #{cc.last_digits} #{I18n.t(:card_expiry_abbreviation)}:#{cc.month.to_s.rjust(2, '0')}/#{cc.year}", + cc.id + ] + end + end end diff --git a/app/services/checkout/params.rb b/app/services/checkout/params.rb index 209b3cc2e4..eaa0a0c815 100644 --- a/app/services/checkout/params.rb +++ b/app/services/checkout/params.rb @@ -23,10 +23,13 @@ module Checkout def apply_strong_parameters @order_params = params.require(:order).permit( - :email, :shipping_method_id, :special_instructions, + :email, :shipping_method_id, :special_instructions, :existing_card_id, bill_address_attributes: ::PermittedAttributes::Address.attributes, ship_address_attributes: ::PermittedAttributes::Address.attributes, - payments_attributes: [:payment_method_id] + payments_attributes: [ + :payment_method_id, + { source_attributes: PermittedAttributes::PaymentSource.attributes } + ] ) end diff --git a/app/views/split_checkout/_payment.html.haml b/app/views/split_checkout/_payment.html.haml index 02e9d7e28e..18459a21a2 100644 --- a/app/views/split_checkout/_payment.html.haml +++ b/app/views/split_checkout/_payment.html.haml @@ -11,14 +11,20 @@ name: "order[payments_attributes][][payment_method_id]", checked: (payment_method.id == selected_payment_method), "data-action": "paymentmethod#selectPaymentMethod", - "data-paymentmethod-description": "#{payment_method.description}" + "data-paymentmethod-id": "paymentmethod#{payment_method.id}" = f.label :payment_method_id, "#{payment_method.name} (#{payment_or_shipping_price(payment_method, @order)})", for: "payment_method_#{payment_method.id}" = f.error_message_on :payment_method, standalone: true - %div - .panel{"data-paymentmethod-target": "panel", style: "display: none"} + - available_payment_methods.each do |payment_method| + .paymentmethod-container{id: "paymentmethod#{payment_method.id}", style: "display: #{payment_method.id == selected_payment_method ? "block" : "none"}"} + - if payment_method.description && !payment_method.description.empty? + .paymentmethod-description.panel + #{payment_method.description} + .paymentmethod-form + = render partial: "split_checkout/payment/#{payment_method.method_type}", locals: { payment_method: payment_method, f: f } + %div.checkout-substep = t("split_checkout.step2.explaination") diff --git a/app/views/split_checkout/payment/_check.html.erb b/app/views/split_checkout/payment/_check.html.erb new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app/views/split_checkout/payment/_gateway.html.haml b/app/views/split_checkout/payment/_gateway.html.haml new file mode 100644 index 0000000000..c8e32ba578 --- /dev/null +++ b/app/views/split_checkout/payment/_gateway.html.haml @@ -0,0 +1,27 @@ += f.fields :bill_address, model: @order.bill_address do |bill_address| + %div.checkout-input + = bill_address.label :firstname, t("split_checkout.step1.your_details.first_name.label") + = bill_address.text_field :firstname, { placeholder: t("split_checkout.step1.your_details.first_name.placeholder") } + = f.error_message_on "bill_address.firstname" + + %div.checkout-input + = bill_address.label :lastname, t("split_checkout.step1.your_details.last_name.label") + = bill_address.text_field :lastname, { placeholder: t("split_checkout.step1.your_details.last_name.placeholder") } + = f.error_message_on "bill_address.lastname" + +.flex{style: "justify-content: space-between; gap: 10px;" } + %div.checkout-input{style: "flex-grow: 2;" } + = f.label :card_number, t("split_checkout.step2.form.card_number.label") + = f.text_field :card_number, { placeholder: t("split_checkout.step2.form.card_number.placeholder") } + + %div.checkout-input{style: "flex: 0 1 100px;"} + = f.label :card_verification_value, t("split_checkout.step2.form.card_verification_value.label") + = f.number_field :card_verification_value, { placeholder: t("split_checkout.step2.form.card_verification_value.placeholder") } + + %div.checkout-input{style: "flex: 0 1 70px;"} + = f.label :card_month, t("split_checkout.step2.form.card_month.label") + = f.number_field :card_month, { placeholder: t("split_checkout.step2.form.card_month.placeholder"), max: 12 } + + %div.checkout-input{style: "flex: 0 1 70px;"} + = f.label :card_year, t("split_checkout.step2.form.card_year.label") + = f.number_field :card_year, { placeholder: t("split_checkout.step2.form.card_year.placeholder"), min: Time.now.year, max: Time.now.year + 10 } diff --git a/app/views/split_checkout/payment/_paypal.html.haml b/app/views/split_checkout/payment/_paypal.html.haml new file mode 100644 index 0000000000..1cc8aa25a7 --- /dev/null +++ b/app/views/split_checkout/payment/_paypal.html.haml @@ -0,0 +1 @@ +-# This file intentionally overrides the view in the spree_paypal_express gem diff --git a/app/views/split_checkout/payment/_stripe_sca.html.haml b/app/views/split_checkout/payment/_stripe_sca.html.haml new file mode 100644 index 0000000000..3b0d20c98e --- /dev/null +++ b/app/views/split_checkout/payment/_stripe_sca.html.haml @@ -0,0 +1,31 @@ +%div{"data-controller": "stripe-cards"} + - if @saved_credit_cards.any? + .checkout-input + %label + = t('split_checkout.step2.form.stripe.use_saved_card') + = select_tag :existing_card, + options_for_select(stripe_card_options(@saved_credit_cards) + [[t('split_checkout.step2.form.stripe.create_new_card'), ""]], @selected_card), + { "data-action": "change->stripe-cards#onSelectCard", "data-stripe-cards-target": "select" } + + .checkout-input{"data-stripe-cards-target": "stripeelements"} + - if @saved_credit_cards.none? + %label + = t('split_checkout.step2.form.stripe.use_new_card') + + %div.stripe-card{ "data-controller": "stripe", "data-stripe-key": "#{Stripe.publishable_key}" } + = hidden_field_tag "order[payments_attributes][][source_attributes][first_name]", @order.bill_address.first_name + = hidden_field_tag "order[payments_attributes][][source_attributes][last_name]", @order.bill_address.last_name + = hidden_field_tag "order[payments_attributes][][source_attributes][month]", nil, { "data-stripe-target": "expMonth" } + = hidden_field_tag "order[payments_attributes][][source_attributes][year]", nil, { "data-stripe-target": "expYear" } + = hidden_field_tag "order[payments_attributes][][source_attributes][cc_type]", nil, { "data-stripe-target": "brand" } + = hidden_field_tag "order[payments_attributes][][source_attributes][last_digits]", nil, { "data-stripe-target": "last4" } + = hidden_field_tag "order[payments_attributes][][source_attributes][gateway_payment_profile_id]", nil, { "data-stripe-target": "pmId" } + + %div.card-element{ "data-stripe-target": "cardElement" } + %div.card-errors{ "data-stripe-target": "cardErrors" } + + - if spree_current_user + .checkout-input + = check_box_tag "order[payments_attributes][][source_attributes][save_requested_by_customer]", 1, false + = label :save_requested_by_customer, t('split_checkout.step2.form.stripe.save_card'), { for: "save_requested_by_customer" } + \ No newline at end of file diff --git a/app/webpacker/controllers/paymentmethod_controller.js b/app/webpacker/controllers/paymentmethod_controller.js index ac1c9dff60..f352430b71 100644 --- a/app/webpacker/controllers/paymentmethod_controller.js +++ b/app/webpacker/controllers/paymentmethod_controller.js @@ -1,9 +1,54 @@ import { Controller } from "stimulus"; export default class extends Controller { - static targets = ["panel"]; + static targets = ["paymentMethod"]; selectPaymentMethod(event) { - this.panelTarget.innerHTML = `${event.target.dataset.paymentmethodDescription}`; - this.panelTarget.style.display = "block"; + const paymentMethodContainerId = event.target.dataset.paymentmethodId; + Array.from( + document.getElementsByClassName("paymentmethod-container") + ).forEach((e) => { + if (e.id === paymentMethodContainerId) { + e.style.display = "block"; + this.addRequiredAttributeOnInputIfNeeded(e); + this.removeDisabledAttributeOnInput(e); + } else { + e.style.display = "none"; + this.removeRequiredAttributeOnInput(e); + this.addDisabledAttributeOnInput(e); + } + }); + } + + getFormElementsArray(container) { + return Array.from(container.querySelectorAll("input, select, textarea")); + } + + addDisabledAttributeOnInput(container) { + this.getFormElementsArray(container).forEach((i) => { + i.disabled = true; + }); + } + + removeDisabledAttributeOnInput(container) { + this.getFormElementsArray(container).forEach((i) => { + i.disabled = false; + }); + } + + removeRequiredAttributeOnInput(container) { + this.getFormElementsArray(container).forEach((i) => { + if (i.required) { + i.dataset.required = i.required; + i.required = false; + } + }); + } + + addRequiredAttributeOnInputIfNeeded(container) { + this.getFormElementsArray(container).forEach((i) => { + if (i.dataset.required === "true") { + i.required = true; + } + }); } } diff --git a/app/webpacker/controllers/stripe_cards_controller.js b/app/webpacker/controllers/stripe_cards_controller.js new file mode 100644 index 0000000000..03eb1ef4ec --- /dev/null +++ b/app/webpacker/controllers/stripe_cards_controller.js @@ -0,0 +1,23 @@ +import { Controller } from "stimulus"; + +// Handles form elements for selecting previously saved Stripe cards from a list of cards + +export default class extends Controller { + static targets = ["stripeelements", "select"]; + + connect() { + this.selectCard(this.selectTarget.value); + } + + onSelectCard(event) { + this.selectCard(event.target.value); + } + + selectCard(cardValue) { + if (cardValue == "") { + this.stripeelementsTarget.style.display = "block"; + } else { + this.stripeelementsTarget.style.display = "none"; + } + } +} diff --git a/app/webpacker/controllers/stripe_controller.js b/app/webpacker/controllers/stripe_controller.js new file mode 100644 index 0000000000..9ebb2deedf --- /dev/null +++ b/app/webpacker/controllers/stripe_controller.js @@ -0,0 +1,63 @@ +import { Controller } from "stimulus" + +export default class extends Controller { + static targets = [ "cardElement", "cardErrors", "expMonth", "expYear", "brand", "last4", "pmId" ]; + static styles = { + base: { + fontFamily: "Roboto, Arial, sans-serif", + fontSize: "16px", + color: "#5c5c5c", + "::placeholder": { + color: "#6c6c6c" + } + } + }; + + connect() { + const stripe = Stripe(this.data.get("key")); + const elements = stripe.elements(); + const form = this.pmIdTarget.form; + const error_container = this.cardErrorsTarget; + const exp_month_field = this.expMonthTarget; + const exp_year_field = this.expYearTarget; + const brand_field = this.brandTarget; + const last4_field = this.last4Target; + const pm_id_field = this.pmIdTarget; + + const stripe_element = elements.create("card", { + style: this.constructor.styles, + hidePostalCode: true + }); + + // Mount Stripe Elements JS to the field and add form validations + stripe_element.mount(this.cardElementTarget); + stripe_element.addEventListener("change", event => { + if (event.error) { + error_container.textContent = event.error.message; + } else { + error_container.textContent = ""; + } + }); + + // Before the form is submitted we send the card details directly to Stripe (via StripeJS), + // and receive a token which represents the card object, and add that token into the form. + form.addEventListener("submit", event => { + event.preventDefault(); + event.stopPropagation(); + + stripe.createPaymentMethod({type: "card", card: stripe_element}).then(response => { + if (response.error) { + error_container.textContent = response.error.message; + } else { + pm_id_field.setAttribute("value", response.paymentMethod.id); + exp_month_field.setAttribute("value", response.paymentMethod.card.exp_month); + exp_year_field.setAttribute("value", response.paymentMethod.card.exp_year); + brand_field.setAttribute("value", response.paymentMethod.card.brand); + last4_field.setAttribute("value", response.paymentMethod.card.last4); + + form.submit(); + } + }); + }); + } +} diff --git a/config/locales/en.yml b/config/locales/en.yml index eec506ff28..bff8d76dec 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1724,6 +1724,24 @@ en: step2: payment_method: title: Payment method + form: + card_number: + label: Card number + placeholder: e.g. 4242 4242 4242 4242 + card_verification_value: + label: CVC + placeholder: 123 + card_month: + label: Month + placeholder: 01 + card_year: + label: Year + placeholder: 2020 + stripe: + use_saved_card: Use saved card + use_new_card: Enter your card identifiers + save_card: Save card for future use + create_new_card: or enter new card details below explaination: You can review and confirm your order in the next step which includes the final costs. submit: Next - Order summary cancel: Back to Your details diff --git a/spec/javascripts/stimulus/paymentmethod_controller_test.js b/spec/javascripts/stimulus/paymentmethod_controller_test.js new file mode 100644 index 0000000000..aab38a9615 --- /dev/null +++ b/spec/javascripts/stimulus/paymentmethod_controller_test.js @@ -0,0 +1,137 @@ +/** + * @jest-environment jsdom + */ + +import { Application } from "stimulus"; +import paymentmethod_controller from "../../../app/webpacker/controllers/paymentmethod_controller"; + +describe("PaymentmethodController", () => { + describe("#selectPaymentMethod", () => { + beforeEach(() => { + document.body.innerHTML = `
+ + + + + +
+ + +
+ +
`; + + const application = Application.start(); + application.register("paymentmethod", paymentmethod_controller); + }); + + it("fill the right payment container", () => { + const paymentMethod1 = document.getElementById("paymentmethod_1"); + const paymentMethod2 = document.getElementById("paymentmethod_2"); + const paymentMethod3 = document.getElementById("paymentmethod_3"); + + const paymentMethod1Container = document.getElementById("paymentmethod1"); + const paymentMethod2Container = document.getElementById("paymentmethod2"); + const paymentMethod3Container = document.getElementById("paymentmethod3"); + + expect(paymentMethod1Container.style.display).toBe("none"); + expect(paymentMethod2Container.style.display).toBe("block"); + expect(paymentMethod3Container.style.display).toBe("none"); + + paymentMethod1.click(); + expect(paymentMethod1Container.style.display).toBe("block"); + expect(paymentMethod2Container.style.display).toBe("none"); + expect(paymentMethod3Container.style.display).toBe("none"); + + paymentMethod3.click(); + expect(paymentMethod1Container.style.display).toBe("none"); + expect(paymentMethod2Container.style.display).toBe("none"); + expect(paymentMethod3Container.style.display).toBe("block"); + }); + + it("handle well the add/remove on 'required' attribute on each input", () => { + const paymentMethod1 = document.getElementById("paymentmethod_1"); + const paymentMethod2 = document.getElementById("paymentmethod_2"); + const paymentMethod3 = document.getElementById("paymentmethod_3"); + + const input1 = document.getElementById("input1"); + const input2 = document.getElementById("input2"); + const input3 = document.getElementById("input3"); + + paymentMethod1.click(); + expect(input1.required).toBe(true); + expect(input2.dataset.required).toBe("true"); + expect(input2.required).toBe(false); + expect(input3.required).toBe(false); + + paymentMethod2.click(); + expect(input2.required).toBe(true); + expect(input1.dataset.required).toBe("true"); + expect(input1.required).toBe(false); + expect(input3.required).toBe(false); + + paymentMethod3.click(); + expect(input1.required).toBe(false); + expect(input2.required).toBe(false); + expect(input3.required).toBe(false); + + paymentMethod1.click(); + expect(input1.required).toBe(true); + expect(input2.dataset.required).toBe("true"); + expect(input2.required).toBe(false); + expect(input3.required).toBe(false); + }); + + it("handle well the add/remove 'disabled='disabled'' attribute on each input/select", () => { + const paymentMethod1 = document.getElementById("paymentmethod_1"); + const paymentMethod2 = document.getElementById("paymentmethod_2"); + const paymentMethod3 = document.getElementById("paymentmethod_3"); + + const input1 = document.getElementById("input1"); + const input2 = document.getElementById("input2"); + const input3 = document.getElementById("input3"); + const select1 = document.getElementById("select1"); + const select2 = document.getElementById("select2"); + const select3 = document.getElementById("select3"); + + paymentMethod1.click(); + expect(input1.disabled).toBe(false); + expect(select1.disabled).toBe(false); + + expect(input2.disabled).toBe(true); + expect(select2.disabled).toBe(true); + expect(input3.disabled).toBe(true); + expect(select3.disabled).toBe(true); + + paymentMethod2.click(); + expect(input2.disabled).toBe(false); + expect(select2.disabled).toBe(false); + + expect(input1.disabled).toBe(true); + expect(select1.disabled).toBe(true); + expect(input3.disabled).toBe(true); + expect(select3.disabled).toBe(true); + + paymentMethod3.click(); + expect(input3.disabled).toBe(false); + expect(select3.disabled).toBe(false); + + expect(input1.disabled).toBe(true); + expect(select1.disabled).toBe(true); + expect(input2.disabled).toBe(true); + expect(select2.disabled).toBe(true); + }); + }); +}); diff --git a/spec/javascripts/stimulus/stripe_cards_controller_test.js b/spec/javascripts/stimulus/stripe_cards_controller_test.js new file mode 100644 index 0000000000..98b590861a --- /dev/null +++ b/spec/javascripts/stimulus/stripe_cards_controller_test.js @@ -0,0 +1,55 @@ +/** + * @jest-environment jsdom + */ + +import { Application } from "stimulus"; +import stripe_cards_controller from "../../../app/webpacker/controllers/stripe_cards_controller"; + +describe("StripeCardsController", () => { + beforeEach(() => { + document.body.innerHTML = `
+ +
+
`; + + const application = Application.start(); + application.register("stripe-cards", stripe_cards_controller); + }); + describe("#connect", () => { + it("initialize with the right display state", () => { + const select = document.getElementById("select"); + select.value = ""; + select.dispatchEvent(new Event("change")); + expect(document.getElementById("stripeelements").style.display).toBe( + "block" + ); + }); + }); + describe("#selectCard", () => { + it("fill the right payment container", () => { + const select = document.getElementById("select"); + select.value = "1"; + select.dispatchEvent(new Event("change")); + + expect(document.getElementById("stripeelements").style.display).toBe( + "none" + ); + + select.value = "2"; + select.dispatchEvent(new Event("change")); + expect(document.getElementById("stripeelements").style.display).toBe( + "none" + ); + + select.value = ""; + select.dispatchEvent(new Event("change")); + expect(document.getElementById("stripeelements").style.display).toBe( + "block" + ); + }); + }); +});