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 a 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 } %>
</div>
<% end %>
</div>
# ...
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 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 %>
</p>
<% end %>
<%= render state, :form => form %>
<% end %>
</div>
<% if state != 'confirm' %>
<div id="checkout-summary" data-hook="checkout_summary_box" class="columns omega four">
<%= render :partial => 'summary', :locals => { :order => order } %>
</div>
<% 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 steps into a single 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 unless 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 potential 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.removeClass("disabled-step")
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.addClass("disabled-step")
elements.find("form input").attr("disabled", "disabled")
elements.find("#checkout-summary, .errorExplanation").hide()
Spree.ready ($) ->
if ($ '#checkout').is('*')
disable_steps()
The code is quite self-explanatory; anyway, this is what happens 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:
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 is the source code of what has been done here.