• Follow us

One Page Checkout with Spree

Alberto Alberto Vena #Code
16 minutes Read
One Page Checkout with Spree

The purpose of this post is to show one way to implement one page checkout with Spree. The goal here is to build a solution which is clean and elegant, especially taking into account the ability of the app to be easily updated when new version of Spree comes out.

How Spree Checkout works

It’s basically a state machine which runs validations and callbacks when trying to go from a step into the next one. It’s really flexible thanks to its own DSL, built to allow checkout flow customization. Default steps are address, delivery, payment and confirm (optional, depending on the payment method used or if the related preference is set).

The strategy

What we are going to do is to just display all steps into a single page changing the steps submit buttons so that they are going to make anynchronous requests each time we hit continue on the current step. This ajax update request will be automatically handled by Spree::CheckoutController. Creating a js view for this action we can easily update the right step: in case of successful responses the next step will be updated otherwise the current step will be updated and validation errors will be displayed.

Also, we are going to implement some UX improvement since it will be better to disable inactive steps hiding outdated summary and preventing form elements from being used.

Confirm step is required

We need the last step to skip the ajax request. This is needed because in case of success it will redirect us to the completion_route which uses a separate controller and view (usually Spree::OrdersController.show).

All steps in one page

Our first goal will be to show all the steps in a single page. We can easily do it by overloading the checkout/edit.html.erb view of our application:

<!-- app/views/spree/checkout/edit.html.erb -->

<div id="checkout" data-hook>
  <% @order.checkout_steps[0...-1].each do |state| %>
    <div class="row checkout_content <%= 'disabled-step' if state != @order.state %>" data-step="<%= state %>"data-hook="checkout_content" id="checkout_<%= state %>">
      <%= render :partial => 'form_wrapper', :locals => { :order => @order, :state => state } %>
  <% end %>

# ...

On a side note, this is the only view we are overloading to have this approach working. To disable one step checkout we can just remove (or rename) this file and the default behavior will be restored.

We are basically cycling on every checkout step. For each step we render a new _form_wrapper partial wich will be responsible to display the step correctly. Here it is the code of this new partial:

<!-- app/views/spree/checkout/_form_wrapper.html.erb -->

<div class="columns <%= if state != 'confirm' then 'alpha twelve' else 'alpha omega sixteen' end %>" data-hook="checkout_form_wrapper">

  <%= render :partial => 'spree/shared/error_messages', :locals => { :target => order } %>

  <%= form_for order, :url => update_checkout_path(state), :remote => (state != 'confirm'), :html => { :id => "checkout_form_#{state}" } do |form| %>
    <% unless order.email? %>
      <p class="field" style='clear: both'>
        <%= form.label :email %><br />
        <%= form.text_field :email %>
    <% end %>

    <%= render state, :form => form %>
  <% end %>
<% if state != 'confirm' %>
  <div id="checkout-summary" data-hook="checkout_summary_box" class="columns omega four">
    <%= render :partial => 'summary', :locals => { :order => order } %>
<% end %>

The code is quite similar to the original edit.html.erb Spree view. We added

:remote => (state != 'confirm'),

to the form_for tag in order to make ajax requests unless the current state is the confirm one.

At this point we’ll have all our step into a single page:

All steps in a singe page

As you can see, since we still don’t know what the user’s address is, the delivery step is empty; it will be filled with the shipping method once we know what shipping method is available for a user within a certain zone.

Advance through the steps with Ajax

So, what happens when we hit “Save and Continue”? An asynchronous request is made and the Spree::CheckoutController#update method is called. Rails responds with a js file since that’s exactly the default way Spree responds to a remote request. We just need to create a js file (we’ll go with coffeescript) which will be executed at each update response:

# app/views/spree/checkout/edit.js.coffee

partial = "<%=j render :partial => 'form_wrapper', :format => :html, :locals => { :state => @order.state, :order => @order } %>"
step = ($ '#checkout_<%= @order.state %>')
error = "<%= flash[:error] %>"

replace_checkout_step(step, partial, error)
  • partial variable will contain the html of the step we need to render (the next one unsless there are errors that prevent to go to the next step);
  • step variable will contain the element we are going to update;
  • error variable will contain eventual errors we want to render;
  • replace_checkout_step method is defined in the assets directory into a proper one-page-checkout support file which will also contain other convenience methods:
# app/assets/javascripts/spree/frontend/one_page_checkout.js.coffee

window.replace_checkout_step = (step, partial, error) ->
  disable_steps true
  step.html(partial) if partial?
  step.find('form.edit_order').prepend("<p class='checkout-error'>#{error}</p>") if !!error
  enable_step step

enable_step = (element) ->
  element.find("form input").removeAttr("disabled")
  element.find("#checkout-summary, .errorExplanation").show()
  # Call Spree step specific javascript
  Spree.onAddress() if element.data('step') == 'address'
  Spree.onPayment() if element.data('step') == 'payment'

disable_steps = (all) ->
  elements = if all? then ($ ".checkout_content") else ($ ".checkout_content.disabled-step")
  elements.find("form input").attr("disabled", "disabled")
  elements.find("#checkout-summary, .errorExplanation").hide()

Spree.ready ($) ->
  if ($ '#checkout').is('*')

The code is quite self-explanatory anyway this is what it’s done everytime replace_checkout_step is called:

  • all steps are disabled (a .disabled-step class is added to all steps, form elements are disabled, summary and errors are hidden);
  • the html code of the right step is updated;
  • eventual flash messages are prepended to the current step’s div;
  • form elements are enabled and summary is shown only into the current step;
  • .disabled-step class is removed from the current step
  • if the current step is address or payment step, default Spree js is called in order to attach default Spree handlers to the just created DOM elements.

Just as a UI improvement we can set an opacity layer via CSS to all disabled steps:

/* app/assets/stylesheets/spree/frontend/one_page_checkout.css.scss */

.disabled-step {
  opacity: .5;

This is the result:

Steps with opacity

Further Developments

As said at the beginning of the post, this is just a basic solution you could extend to add more features and custom style; for example you could just show the step title if it’s a disabled step. Other useful features are enabling popstate to let URLs update reflecting the current step and automatic and animated scroll while a new step is reached.

If you want to give it a try, here it is the source code of what has been done here.