Merge pull request #7840 from jibees/new-checkout-first-step

New checkout first + second step
This commit is contained in:
Matt-Yorkley
2021-08-03 12:00:26 +02:00
committed by GitHub
19 changed files with 757 additions and 8 deletions

View File

@@ -1,6 +1,7 @@
$ofn-brand: #f27052;
$distributor-header-shadow: 0 1px 0 rgba(0, 0, 0, 0.05), 0 8px 6px -6px rgba(0, 0, 0, 0.2);
$distributor-header-shadow: 0 1px 0 rgba(0, 0, 0, 0.05),
0 8px 6px -6px rgba(0, 0, 0, 0.2);
// e.g. australia, uk, norway specific color
@@ -36,8 +37,8 @@ $med-grey: #666;
$med-drk-grey: #444;
$dark-grey: #333;
$light-grey: #ddd;
$light-grey-transparency: rgba(0, 0, 0, .1);
$very-light-grey-transparency: rgba(0, 0, 0, .05);
$light-grey-transparency: rgba(0, 0, 0, 0.1);
$very-light-grey-transparency: rgba(0, 0, 0, 0.05);
$black: #000;
$white: #fff;
@@ -75,3 +76,7 @@ $social-facebook: #3b5998;
$social-instagram: #e1306c;
$social-linkedin: #0e76a8;
$social-twitter: #00acee;
// Typography
$min-accessible-grey: #666;
$darker-grey: #222;

View File

@@ -0,0 +1,117 @@
.checkout-tab {
height: 4rem;
display: flex;
flex-direction: column;
justify-content: center;
span {
font-size: 1.3rem;
@include headingFont;
}
&:not(.selected) {
background-color: $white;
border-bottom: 5px solid $min-accessible-grey;
span {
color: $min-accessible-grey;
}
}
&.selected {
background-color: $ofn-brand;
span {
color: $white;
text-decoration: underline;
}
}
}
.checkout-step {
margin-right: auto;
margin-left: auto;
margin-top: 3rem;
.checkout-substep {
margin-bottom: 1rem;
margin-top: 5rem;
&:first-child {
margin-top: 3rem;
}
}
.checkout-title {
font-size: 1.1rem;
@include headingFont;
font-weight: $font-weight-bold;
text-decoration: underline;
color: $darker-grey;
margin-bottom: 1.5rem;
}
.checkout-input {
margin-bottom: 1.5rem;
.field_with_errors {
label {
font-weight: $font-weight-bold;
color: $red-700;
}
input {
border-color: $red-700;
}
}
label {
margin-bottom: 0.3rem;
}
input,
select {
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;
}
#distributor_address.panel {
font-size: 0.875rem;
padding: 1rem;
span {
white-space: pre-wrap;
}
}
}
.checkout-submit {
margin-top: 5rem;
margin-bottom: 5rem;
.button {
width: 100%;
margin-bottom: 2rem;
&.primary {
background-color: $orange-500;
}
&.cancel {
background-color: $grey-100;
color: $black;
}
}
}
}

View File

@@ -132,7 +132,7 @@ ul.check-list {
}
.light-grey {
color: #666666;
color: $min-accessible-grey;
}
.pad {

View File

@@ -0,0 +1,9 @@
class SplitCheckoutConstraint
def matches?(request)
Flipper.enabled? :split_checkout, current_user(request)
end
def current_user(request)
@spree_current_user ||= request.env['warden'].user
end
end

View File

@@ -0,0 +1,295 @@
# frozen_string_literal: true
require 'open_food_network/address_finder'
class SplitCheckoutController < ::BaseController
layout 'darkswarm'
include OrderStockCheck
include Spree::BaseHelper
helper 'terms_and_conditions'
helper 'checkout'
# We need pessimistic locking to avoid race conditions.
# Otherwise we fail on duplicate indexes or end up with negative stock.
prepend_around_action CurrentOrderLocker, only: [:edit, :update]
prepend_before_action :set_checkout_step
prepend_before_action :check_hub_ready_for_checkout
prepend_before_action :check_order_cycle_expiry
prepend_before_action :require_order_cycle
prepend_before_action :require_distributor_chosen
before_action :load_order, :load_shipping_methods, :load_countries
before_action :ensure_order_not_completed
before_action :ensure_checkout_allowed
before_action :handle_insufficient_stock
before_action :associate_user
before_action :check_authorization
before_action :enable_embedded_shopfront
helper 'spree/orders'
def edit
return handle_redirect_from_stripe if valid_payment_intent_provided?
redirect_to_step unless @checkout_step
# This is only required because of spree_paypal_express. If we implement
# a version of paypal that uses this controller, and more specifically
# the #action_failed method, then we can remove this call
# OrderCheckoutRestart.new(@order).call
rescue Spree::Core::GatewayError => e
rescue_from_spree_gateway_error(e)
end
def update
if @order.update(order_params)
OrderWorkflow.new(@order).advance_to_payment
if @order.state == "payment"
redirect_to checkout_step_path(:payment)
else
render :edit
end
else
flash[:error] = "Saving failed, please update the highlighted fields"
render :edit
end
end
# Clears the cached order. Required for #current_order to return a new order
# to serve as cart. See https://github.com/spree/spree/blob/1-3-stable/core/lib/spree/core/controller_helpers/order.rb#L14
# for details.
def expire_current_order
session[:order_id] = nil
@current_order = nil
end
private
def set_checkout_step
@checkout_step = params[:step]
end
def order_params
params.require(:order).permit(
:email, :shipping_method_id, :special_instructions,
bill_address_attributes: PermittedAttributes::Address.attributes,
ship_address_attributes: PermittedAttributes::Address.attributes
)
end
def redirect_to_step
if @order.state == "payment"
if true# order.has_no_payment_method_chosen?
redirect_to checkout_step_path(:payment)
else
redirect_to checkout_step_path(:summary)
end
else
redirect_to checkout_step_path(:details)
end
end
def check_authorization
authorize!(:edit, current_order, session[:access_token])
end
def ensure_checkout_allowed
redirect_to main_app.cart_path unless @order.checkout_allowed?
end
def ensure_order_not_completed
redirect_to main_app.cart_path if @order.completed?
end
def load_shipping_methods
@shipping_methods = Spree::ShippingMethod.for_distributor(@order.distributor).order(:name)
end
def load_countries
@countries = available_countries.map { |c| [c.name, c.id] }
@countries_with_states = available_countries.map { |c| [c.id, c.states.map { |s| [s.name, s.id] }] }
end
def load_order
@order = current_order
redirect_to(main_app.shop_path) && return if redirect_to_shop?
redirect_to_cart_path && return unless valid_order_line_items?
before_address
setup_for_current_state
end
def redirect_to_shop?
!@order ||
!@order.checkout_allowed? ||
@order.completed?
end
def valid_order_line_items?
@order.insufficient_stock_lines.empty? &&
OrderCycleDistributedVariants.new(@order.order_cycle, @order.distributor).
distributes_order_variants?(@order)
end
def redirect_to_cart_path
respond_to do |format|
format.html do
redirect_to main_app.cart_path
end
format.json do
render json: { path: main_app.cart_path }, status: :bad_request
end
end
end
def setup_for_current_state
method_name = :"before_#{@order.state}"
__send__(method_name) if respond_to?(method_name, true)
end
def before_address
associate_user
finder = OpenFoodNetwork::AddressFinder.new(@order.email, @order.customer, spree_current_user)
@order.bill_address = finder.bill_address
@order.ship_address = finder.ship_address
end
def before_payment
current_order.payments.destroy_all if request.put?
end
def valid_payment_intent_provided?
return false unless params["payment_intent"]&.starts_with?("pi_")
last_payment = OrderPaymentFinder.new(@order).last_payment
@order.state == "payment" &&
last_payment&.state == "requires_authorization" &&
last_payment&.response_code == params["payment_intent"]
end
def handle_redirect_from_stripe
return checkout_failed unless @order.process_payments!
if OrderWorkflow.new(@order).next && order_complete?
checkout_succeeded
redirect_to(order_path(@order)) && return
else
checkout_failed
end
end
def checkout_workflow(shipping_method_id)
while @order.state != "complete"
if @order.state == "payment"
return if redirect_to_payment_gateway
return action_failed unless @order.process_payments!
end
next if OrderWorkflow.new(@order).next({ shipping_method_id: shipping_method_id })
return action_failed
end
update_response
end
def redirect_to_payment_gateway
return unless params&.dig(:order)&.dig(:payments_attributes)&.first&.dig(:payments_attributes)
redirect_path = Checkout::PaypalRedirect.new(params).path
redirect_path = Checkout::StripeRedirect.new(params, @order).path if redirect_path.blank?
return if redirect_path.blank?
render json: { path: redirect_path }, status: :ok
true
end
def order_error
if @order.errors.present?
@order.errors.full_messages.to_sentence
else
t(:payment_processing_failed)
end
end
def update_response
if order_complete?
checkout_succeeded
update_succeeded_response
else
action_failed(RuntimeError.new("Order not complete after the checkout workflow"))
end
end
def order_complete?
@order.state == "complete" || @order.completed?
end
def checkout_succeeded
Checkout::PostCheckoutActions.new(@order).success(self, params, spree_current_user)
session[:access_token] = current_order.token
flash[:notice] = t(:order_processed_successfully)
end
def update_succeeded_response
respond_to do |format|
format.html do
respond_with(@order, location: order_path(@order))
end
format.json do
render json: { path: order_path(@order) }, status: :ok
end
end
end
def action_failed(error = RuntimeError.new(order_error))
checkout_failed(error)
action_failed_response
end
def checkout_failed(error = RuntimeError.new(order_error))
Bugsnag.notify(error)
Checkout::PostCheckoutActions.new(@order).failure
end
def action_failed_response
respond_to do |format|
format.html do
render :edit
end
format.json do
discard_flash_errors
render json: { errors: @order.errors, flash: flash.to_hash }.to_json, status: :bad_request
end
end
end
def rescue_from_spree_gateway_error(error)
flash[:error] = t(:spree_gateway_error_flash_for_checkout, error: error.message)
action_failed(error)
end
def permitted_params
PermittedAttributes::Checkout.new(params).call
end
def discard_flash_errors
# Marks flash errors for deletion after the current action has completed.
# This ensures flash errors generated during XHR requests are not persisted in the
# session for longer than expected.
flash.discard(:error)
end
end

View File

@@ -4,6 +4,17 @@ module ApplicationHelper
include RawParams
include Pagy::Frontend
def error_message_on(object, method, _options = {})
object = convert_to_model(object)
obj = object.respond_to?(:errors) ? object : instance_variable_get("@#{object}")
if obj && obj.errors[method].present?
errors = obj.errors[method].map { |err| h(err) }.join('<br />').html_safe
content_tag(:span, errors, class: 'formError')
else
''
end
end
def feature?(feature, user = nil)
OpenFoodNetwork::FeatureToggle.enabled?(feature, user)
end

View File

@@ -145,7 +145,7 @@ module Spree
adjustment.originator = payment_method
adjustment.label = adjustment_label
adjustment.save
else
elsif payment_method.present?
payment_method.create_adjustment(adjustment_label, self, true)
adjustment.reload
end

View File

@@ -33,5 +33,4 @@
.small-12.medium-4.columns
= render partial: "checkout/summary"
= render partial: "shared/footer"

View File

@@ -0,0 +1,110 @@
= f.fields :bill_address, model: @order.bill_address do |bill_address|
%div.checkout-substep
-# YOUR DETAILS
%div.checkout-title
= t("split_checkout.step1.your_details.title")
%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"
%div.checkout-input
= f.label :email, t("split_checkout.step1.your_details.email.label")
= f.text_field :email, { placeholder: t("split_checkout.step1.your_details.email.placeholder") }
= f.error_message_on :email
%div.checkout-input
= bill_address.label :phone, t("split_checkout.step1.your_details.phone.label")
= bill_address.text_field :phone, { placeholder: t("split_checkout.step1.your_details.phone.placeholder") }
= f.error_message_on "bill_address.phone"
%div.checkout-substep{ "data-controller": "dependant-select", "data-dependant-select-options-value": @countries_with_states }
-# BILLING ADDRESS
%div.checkout-title
= t("split_checkout.step1.billing_address.title")
%div.checkout-input
= bill_address.label :address1, t("split_checkout.step1.billing_address.address1.label")
= bill_address.text_field :address1, { placeholder: t("split_checkout.step1.billing_address.address1.placeholder") }
= f.error_message_on "bill_address.address1"
%div.checkout-input
= bill_address.label :address2, t("split_checkout.step1.billing_address.address2.label")
= bill_address.text_field :address2, { placeholder: t("split_checkout.step1.billing_address.address2.placeholder") }
= f.error_message_on "bill_address.address2"
%div.checkout-input
= bill_address.label :city, t("split_checkout.step1.billing_address.city.label")
= bill_address.text_field :city, { placeholder: t("split_checkout.step1.billing_address.city.placeholder") }
= f.error_message_on "bill_address.city"
%div.checkout-input
= bill_address.label :state_id, t("split_checkout.step1.billing_address.state_id.label")
= bill_address.select :state_id, @countries_with_states, { }, { "data-dependant-select-target": "select" }
%div.checkout-input
= bill_address.label :zipcode, t("split_checkout.step1.billing_address.zipcode.label")
= bill_address.text_field :zipcode, { placeholder: t("split_checkout.step1.billing_address.zipcode.placeholder") }
= f.error_message_on "bill_address.zipcode"
%div.checkout-input
= bill_address.label :country_id, t("split_checkout.step1.billing_address.country_id.label")
= bill_address.select :country_id, @countries, { selected: @order.bill_address.country_id || DefaultCountry.id }, {"data-dependant-select-target": "source", "data-action": "dependant-select#handleSelectChange"}
- if spree_current_user||true
%div.checkout-input
= f.check_box :checkout_default_bill_address
= f.label :checkout_default_bill_address, t(:checkout_default_bill_address)
%div.checkout-substep{ "data-controller": "toggle shippingmethod" }
-# DELIVERY ADDRESS
%div.checkout-title
= t("split_checkout.step1.delivery_address.title")
- selected_shipping_method = @order.shipping_method&.id
- @shipping_methods.each do |shipping_method|
%div.checkout-input
= fields_for shipping_method do |shipping_method_form|
= shipping_method_form.radio_button :name, shipping_method.id,
id: "shipping_method_#{shipping_method.id}",
checked: (shipping_method.id == selected_shipping_method),
"data-description": shipping_method.description,
"data-action": "toggle#toggle shippingmethod#selectShippingMethod",
"data-toggle-show": shipping_method.require_ship_address
= shipping_method_form.label shipping_method.id, shipping_method.name, {for: "shipping_method_" + shipping_method.id.to_s }
%em.light
= payment_method_price(shipping_method, @order)
%div.checkout-input{ "data-toggle-target": "content", style: "display: none" }
= f.check_box "Checkout.ship_address_same_as_billing", { id: "Checkout.ship_address_same_as_billing" }
= f.label "Checkout.ship_address_same_as_billing", t(:checkout_address_same), { for: "Checkout.ship_address_same_as_billing" }
- if spree_current_user
%div.checkout-input{ "data-toggle-target": "content", style: "display: none" }
= f.check_box "Checkout.default_ship_address", { id: "Checkout.default_ship_address" }
= f.label "Checkout.default_ship_address", t(:checkout_default_ship_address), { for: "Checkout.default_ship_address" }
%div.checkout-input{"data-shippingmethod-target": "shippingMethodDescription"}
#distributor_address.panel
%span{"data-shippingmethod-target": "shippingMethodDescriptionContent"}
%br/
%br/
- if @order.order_cycle.pickup_time_for(@order.distributor)
= t :checkout_ready_for
= @order.order_cycle.pickup_time_for(@order.distributor)
.div.checkout-input
= f.label :special_instructions, t(:checkout_instructions)
= f.text_area :special_instructions, size: "60x4"
%div.checkout-submit
= f.submit t("split_checkout.step1.submit"), class: "button primary", disabled: @terms_and_conditions_accepted == false || @platform_tos_accepted == false
%a.button.cancel{href: main_app.cart_path}
= t("split_checkout.step1.cancel")

View File

@@ -0,0 +1,7 @@
- content_for :injection_data do
= inject_available_payment_methods
= inject_saved_credit_cards
%div.checkout-step.medium-6
= form_with url: checkout_update_path(@checkout_step), model: @order, method: :put do |f|
= render "split_checkout/#{@checkout_step}", f: f

View File

@@ -0,0 +1,22 @@
%div.checkout-substep{"data": {"controller": "paymentmethod"}}
%div.checkout-title
= t("split_checkout.step2.payment_method.title")
- available_payment_methods.each do |payment_method|
%div.checkout-input
= f.radio_button :payment_method_id, payment_method.id,
id: "payment_method_#{payment_method.id}",
"data-action": "paymentmethod#selectPaymentMethod",
"data-paymentmethod-description": "#{payment_method.description}"
= f.label payment_method.id, "#{payment_method.name} (#{payment_method_price(payment_method, @order)})", {for: "payment_method_" + payment_method.id.to_s }
%div
.panel{"data-paymentmethod-target": "panel", style: "display: none"}
%div.checkout-substep
= t("split_checkout.step2.explaination")
%div.checkout-submit
= f.submit t("split_checkout.step2.submit"), class: "button primary", disabled: @terms_and_conditions_accepted == false || @platform_tos_accepted == false
%a.button.cancel{href: main_app.checkout_step_path(:details)}
= t("split_checkout.step2.cancel")

View File

@@ -0,0 +1,10 @@
%div.flex
%div.columns.three.text-center.checkout-tab{"class": ("selected" if @checkout_step == "details")}
%span
= t("split_checkout.your_details")
%div.columns.three.text-center.checkout-tab{"class": ("selected" if @checkout_step == "payment")}
%span
= t("split_checkout.payment_method")
%div.columns.three.text-center.checkout-tab{"class": ("selected" if @checkout_step == "summary")}
%span
= t("split_checkout.order_summary")

View File

@@ -0,0 +1,32 @@
- content_for(:title) do
= t :checkout_title
- content_for :injection_data do
= inject_enterprise_and_relatives
= inject_available_countries
.darkswarm.footer-pad
- content_for :order_cycle_form do
%closing
= t :checkout_now
%p
= t :checkout_order_ready
%strong
= pickup_time current_order_cycle
- content_for :ordercycle_sidebar do
.show-for-large-up.large-4.columns
= render partial: "shopping_shared/order_cycles"
= render partial: "shopping_shared/header"
.sub-header.show-for-medium-down
= render partial: "shopping_shared/order_cycles"
%checkout.row
.small-12.medium-12.columns
= render partial: "split_checkout/tabs"
= render partial: "split_checkout/form"
= render partial: "shared/footer"

View File

@@ -0,0 +1,27 @@
import { Controller } from "stimulus";
export default class extends Controller {
static targets = ["source", "select"];
static values = { options: Array };
connect() {
this.populateSelect(parseInt(this.sourceTarget.value));
}
handleSelectChange() {
this.populateSelect(parseInt(this.sourceTarget.value));
}
populateSelect(sourceId) {
const allOptions = this.optionsValue;
const options = allOptions.find((option) => option[0] === sourceId)[1];
const selectBox = this.selectTarget;
selectBox.innerHTML = "";
options.forEach((item) => {
const opt = document.createElement("option");
opt.value = item[1];
opt.innerHTML = item[0];
selectBox.appendChild(opt);
});
}
}

View File

@@ -0,0 +1,9 @@
import { Controller } from "stimulus";
export default class extends Controller {
static targets = ["panel"];
selectPaymentMethod(event) {
this.panelTarget.innerHTML = `<span>${event.target.dataset.paymentmethodDescription}</span>`;
this.panelTarget.style.display = "block";
}
}

View File

@@ -0,0 +1,24 @@
import { Controller } from "stimulus";
export default class extends Controller {
static targets = [
"shippingMethodDescription",
"shippingMethodDescriptionContent",
];
connect() {
// Hide shippingMethodDescription by default
this.shippingMethodDescriptionTarget.style.display = "none";
}
selectShippingMethod(event) {
const input = event.target;
if (input.tagName === "INPUT") {
if (input.dataset.description.length > 0) {
this.shippingMethodDescriptionTarget.style.display = "block";
this.shippingMethodDescriptionContentTarget.innerText =
input.dataset.description;
} else {
this.shippingMethodDescriptionTarget.style.display = "none";
this.shippingMethodDescriptionContentTarget.innerText = null;
}
}
}
}

View File

@@ -0,0 +1,12 @@
import { Controller } from "stimulus";
export default class extends Controller {
static targets = ["content"];
toggle(event) {
const input = event.currentTarget;
this.contentTargets.forEach((t) => {
t.style.display = input.dataset.toggleShow === "true" ? "block" : "none";
});
}
}

View File

@@ -1620,6 +1620,57 @@ en:
checkout_back_to_cart: "Back to Cart"
cost_currency: "Cost Currency"
split_checkout:
your_details: 1 - Your details
payment_method: 2 - Payment method
order_summary: 3 - Order summary
step1:
your_details:
title: Your details
first_name:
label: First Name
placeholder: e.g. Jane
last_name:
label: Last Name
placeholder: e.g. Doe
email:
label: Email
placeholder: e.g. Janedoe@email.com
phone:
label: Phone number
placeholder: e.g. 07987654321
billing_address:
title: Billing address
address1:
label: Address (Street + House Number)
placeholder: e.g. Flat 1 Elm apartments
address2:
label: Additional address info (optional)
placeholder: e.g. Cavalier avenur
city:
label: City
placeholder: e.g. London
state_id:
label: State
zipcode:
label: Postcode
placeholder: e.g. SW11 3QN
country_id:
label: Country
delivery_address:
title: Delivery address
submit: Next - Payment method
cancel: Back to Edit basket
step2:
payment_method:
title: Payment method
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
errors:
required: Field cannot be blank
invalid_number: "Please enter a valid phone number"
invalid_email: "Please enter a valid email address"
order_paid: PAID
order_not_paid: NOT PAID
order_total: Total order

View File

@@ -69,8 +69,17 @@ Openfoodnetwork::Application.routes.draw do
resources :webhooks, only: [:create]
end
get '/checkout', to: 'checkout#edit' , as: :checkout
put '/checkout', to: 'checkout#update' , as: :update_checkout
constraints SplitCheckoutConstraint.new do
get '/checkout', to: 'split_checkout#edit'
constraints step: /(details|payment|summary)/ do
get '/checkout/:step', to: 'split_checkout#edit', as: :checkout_step
put '/checkout/:step', to: 'split_checkout#update', as: :checkout_update
end
end
get '/checkout', to: 'checkout#edit'
put '/checkout', to: 'checkout#update', as: :update_checkout
get '/checkout/:state', to: 'checkout#edit', as: :checkout_state
get '/checkout/paypal_payment/:order_id', to: 'checkout#paypal_payment', as: :paypal_payment