Merge pull request #8503 from jibees/split-checkout-payment-forms

Split checkout payment forms
This commit is contained in:
Filipe
2021-12-15 12:57:03 +00:00
committed by GitHub
15 changed files with 467 additions and 26 deletions

View File

@@ -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;

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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")

View File

@@ -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 }

View File

@@ -0,0 +1 @@
-# This file intentionally overrides the view in the spree_paypal_express gem

View File

@@ -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" }

View File

@@ -1,9 +1,54 @@
import { Controller } from "stimulus";
export default class extends Controller {
static targets = ["panel"];
static targets = ["paymentMethod"];
selectPaymentMethod(event) {
this.panelTarget.innerHTML = `<span>${event.target.dataset.paymentmethodDescription}</span>`;
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;
}
});
}
}

View File

@@ -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";
}
}
}

View File

@@ -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();
}
});
});
}
}

View File

@@ -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

View File

@@ -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 = `<div data-controller="paymentmethod">
<span id="paymentmethod_1" data-action="click->paymentmethod#selectPaymentMethod" data-paymentmethod-id="paymentmethod1" />
<span id="paymentmethod_2" data-action="click->paymentmethod#selectPaymentMethod" data-paymentmethod-id="paymentmethod2" />
<span id="paymentmethod_3" data-action="click->paymentmethod#selectPaymentMethod" data-paymentmethod-id="paymentmethod3" />
<div class="paymentmethod-container" style="display: none;" id="paymentmethod1">
<input type="number" required id="input1" />
<select id="select1" >
<option value="1">1</option>
</select>
</div>
<div class="paymentmethod-container" style="display: block;" id="paymentmethod2">
<input type="number" required="true" id="input2" />
<select id="select2" >
<option value="1">1</option>
</select>
</div>
<div class="paymentmethod-container" style="display: none;" id="paymentmethod3">
<input type="number" id="input3" />
<select id="select3" >
<option value="1">1</option>
</select>
</div>
</div>`;
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);
});
});
});

View File

@@ -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 = `<div data-controller="stripe-cards">
<select data-action="change->stripe-cards#onSelectCard" id="select">
<option value="">Blank</option>
<option value="1">Card #1</option>
<option value="2">Card #2</option>
</select>
<div data-stripe-cards-target="stripeelements" id="stripeelements" />
</div>`;
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"
);
});
});
});