Single page checkout


(Mark Hamstra) #1

Single page (or a more streamlined) checkout has been a common request.

While I personally don’t think there is such a thing as a single page checkout when you need to deal with taxes, different shipping methods (perhaps even different delivery types), and different payment methods, there is definitely plenty of possibilities in Commerce to handle things in a more compact fashion. The default checkout is rather lengthy in that every possible step is a separate step, but you can optimize that when you have your particular use case.

The way I personally envision a single page checkout in Commerce is a JavaScript-enhanced checkout, themed to have all the different aspects on the same page. After @isaacniebeling bringing up the topic on Slack, I just spent about an hour on a quick mockup of how that could work.

The result

First, here’s a screencast of the result.

=> http://recordit.co/Bzoh7v3war

This relies on 2 things.

  1. Templates that are set up in a way that it all looks like it’s on the same page.
  2. Some JavaScript that intercepts submit events and navigation clicks, and handles them with AJAX instead.

Templates

I’ve set up a custom theme named “singlepage”, and added the following files under core/components/commerce/templates/singlepage. (Note: Ideally, you would not place them in the commerce core directory, see the linked docs for better instructions)

frontend/checkout/wrapper.twig

This template is used to wrap around the entire checkout (but not the cart), and is where I added the javascript needed to intercept submits and clicks. You could also add the javascript to a file and load it in your templates instead.

Most of this template is unchanged, other than the added script tags.

<div class="c-wrapper">
    {% if commerce_mode == 'test' %}
        <div class="c-checkout-mode">
            <p>{{ lex('commerce.shop_in_testmode') }}</p>
        </div>
    {% endif %}
    <div class="c-steps-wrapper">
        <ol class="c-steps-indicator c-steps-{{ steps|length }}">
            {% spaceless %}
            {% for stepKey, step in steps %}
                <li class="c-step {% if stepKey == currentKey %}active{% endif %}">
                    {% if step.allowed %}<a href="{{ step.link }}">{% endif %}
                        {{ lex('checkout.step_' ~ stepKey) }}
                    {% if step.allowed %}</a>{% endif %}
                </li>
            {% endfor %}
            {% endspaceless %}
        </ol>
    </div>

    <div class="c-messages">
        {% include 'frontend/response-messages.twig' %}
    </div>

    <div class="c-step-wrapper">
        {% autoescape false %}
        {{ output }}
        {% endautoescape %}
    </div>
</div>
<script type="text/javascript" src="https://code.jquery.com/jquery-3.2.1.min.js"></script>
<script type="text/javascript">
    $(function() {
        var messages = $('.c-messages'),
            stepWrapper = $('.c-step-wrapper');

        $(document).on('submit', '.c-step-wrapper form', function(e) {
            e.preventDefault();

            var form = $(this),
                action = form.attr('action'),
                values = form.serialize();

            $.ajax({
                url: action,
                data: values,
                dataType: 'json',
                success: updateFromResponse
            });
        });

        $(document).on('click', '.c-steps-indicator a', function(e) {
            e.preventDefault();

            var link = $(this),
                href = link.attr('href'),
                indicators = $('.c-steps-indicator .active');
            indicators.each(function(i, item) {
                $(item).removeClass('active');
            });
            link.parent().addClass('active');

            $.ajax({
                url: href,
                dataType: 'json',
                success: updateFromResponse
            });


        });

        function updateFromResponse(result) {
            // Update the message holder
            if (result.message && result.message.length > 0) {
                messages.html(result.message);
            }
            else {
                messages.html('');
            }

            if (result.success) {
                stepWrapper.html(result.output);

                // If we have an off-site redirect, redirect the user
                // works best with link_tag_scheme set to full
                if (result.redirect) {
                    if (result.redirect.substring(0, location.origin.length) !== location.origin) {
                        window.location = result.redirect;
                    }
                    else {
                        $.ajax({
                            url: result.redirect,
                            dataType: 'json',
                            success: updateFromResponse
                        });
                    }
                }
            }
            else {
                alert('Had a failure');
            }
        }
    });
</script>

frontend/checkout/onepage.twig

I’ve added a new template, onepage.twig, to hold my “single page”. In this template, first I show the product summary, and then I’ve added new blocks (address, shipping, payment) that are shown side-by-side (with Flexbox), which I’ll override in the different steps.

<div class="c-checkout c-checkout-onepage">
    {% include 'frontend/response-messages.twig' %}

    <table class="c-cart-summary-items">
        <tbody>
        {% for item in items %}
            <tr class="c-cart-summary-item">
                <td class="c-cart-summary-item-details">
                    {{ item.quantity }}x&nbsp;<b>{{ item.name }}</b>
                    <br>
                    <small><code>{{ item.sku }}</code>, {{ item.price_formatted }} {{ lex('commerce.cart.each') }}</small>
                </td>
                <td class="c-cart-summary-item-total">

                    {% if tax_exclusive %}
                        {% if item.discount != 0 %}
                            <strike>{{ item.total_formatted }}</strike> {{ item.subtotal_formatted }}
                            <br>
                            <small>{{ lex('commerce.cart.item.discount', {'discount': item.discount_formatted}) }}</small>
                        {% else %}
                            {{ item.total_formatted }}
                        {% endif %}

                        {% if item.tax != 0 %}
                            <br>
                            <small>{{ lex('commerce.cart.item.plus_taxes', {'tax': item.tax_formatted}) }}</small>
                        {% endif %}
                    {% else %}
                        {% if item.discount != 0 %}
                            <strike>{{ item.subtotal_formatted }}</strike> {{ item.total_before_tax_formatted }}
                            <br>
                            <small>{{ lex('commerce.cart.item.discount', {'discount': item.discount_formatted}) }}</small>
                        {% else %}
                            {{ item.total_before_tax_formatted }}
                        {% endif %}
                        {% if item.tax != 0 %}
                            <br>
                            <small>{{ lex('commerce.cart.item.incl_taxes', {'tax': item.tax_formatted}) }}</small>
                        {% endif %}
                    {% endif %}
                </td>
            </tr>
        {% endfor %}
        </tbody>
        <tfoot>
        <tr class="c-cart-summary-totals-row-subtotal">
            <th class="c-cart-summary-totals-label c-cart-summary-totals-label-subtotal">
                {{ lex('commerce.subtotal') }}
            </th>
            <td class="c-cart-summary-totals-value c-cart-summary-totals-subtotal">
                {{ order.subtotal_formatted }}
            </td>
        </tr>
        {% if order.discount != 0 %}
            <tr class="c-cart-summary-totals-row-discount">
                <th class="c-cart-summary-totals-label c-cart-summary-totals-label-discount">
                    {{ lex('commerce.discount') }}
                </th>
                <td class="c-cart-summary-totals-value c-cart-summary-totals-discount">
                    - {{ order.discount_formatted }}
                </td>
            </tr>
        {% endif %}
        {% if order.tax != 0 %}
            <tr class="c-cart-summary-totals-row-taxes">
                <th class="c-cart-summary-totals-label c-cart-summary-totals-label-taxes">
                    {% if tax_exclusive %}
                        {{ lex('commerce.taxes') }}
                    {% else %}
                        {{ lex('commerce.tax_included') }}
                    {% endif %}
                </th>
                <td class="c-cart-summary-totals-value c-cart-summary-totals-taxes">
                    {{ order.tax_formatted }}
                </td>
            </tr>
        {% endif %}
        {% for rate in tax_rates %}
            <tr class="c-cart-summary-totals-taxes-breakdown">
                <td class="c-cart-summary-totals-label">{{ rate.name}} ({{ rate.percentage_formatted }} {% if rate.is_inclusive %} of {% else %} over {% endif %}{{ rate.total_taxed_amount_formatted }})</td>
                <td class="c-cart-summary-totals-value">{{ rate.total_tax_amount_formatted }}</td>
            </tr>
        {% endfor %}
        {% for shipment in shipments %}
            {% if shipment.method.id > 0 %}
                <tr class="c-cart-summary-totals-row-shipping c-cart-summary-totals-row-shipment">
                    <th class="c-cart-summary-totals-label c-cart-summary-totals-label-shipping c-cart-summary-totals-label-shipment">
                        {{ shipment.method.name }}
                    </th>
                    <td class="c-cart-summary-totals-value c-cart-summary-totals-shipping">
                        {{ shipment.fee_formatted }}
                    </td>
                </tr>
            {% endif %}
        {% endfor %}
        {% if order.transaction != 0 %}
            <tr class="c-cart-summary-totals-row-transaction">
                <th class="c-cart-summary-totals-label c-cart-summary-totals-label-transaction">
                    {{ lex('commerce.transaction') }}
                </th>
                <td class="c-cart-summary-totals-value c-cart-summary-totals-transaction">
                    {{ order.transaction_formatted }}
                </td>
            </tr>
        {% endif %}
        <tr class="c-cart-summary-totals-row-total">
            <th class="c-cart-summary-totals-label c-cart-summary-totals-label-total">
                {{ lex('commerce.total') }}
            </th>
            <td class="c-cart-summary-totals-value c-cart-summary-totals-total">
                {{ order.total_formatted }}
            </td>
        </tr>
        <tr class="c-cart-summary-totals-row-total-due">
            <th class="c-cart-summary-totals-label c-cart-summary-totals-label-total-due">
                {{ lex('commerce.total_due') }}
            </th>
            <td class="c-cart-summary-totals-value c-cart-summary-totals-total-due">
                {{ order.total_due_formatted }}
            </td>
        </tr>

        </tfoot>
    </table>

    <div style="display: flex;">
        <div style="flex: 1;">
            <h2>Address</h2>
            {% block address %}
                {% if shipping_address.id > 0 %}
                    <div class="c-shipping">
                        <h3>{{ lex('commerce.shipping_address') }}</h3>
                        {% if shipping_method.id %}
                            <p class="c-shipping-summary">
                                {{ lex('commerce.cart.shipping_with_method') }}
                                <b>{{ shipping_method.name }}</b>
                            </p>
                        {% endif %}
                        <div class="c-shipping-address">
                            {{ shipping_address|format_address }}
                        </div>
                    </div>
                {% endif %}
                {% if billing_address.id > 0 %}
                    <div class="c-billing">
                        <h3>{{ lex('commerce.billing_address') }}</h3>
                        <div class="c-billing-address">
                            {{ billing_address|format_address }}
                        </div>
                    </div>
                {% endif %}
            {% endblock %}
        </div>
        <div style="flex: 1;">
            <h2>Shipping</h2>
            {% block shipping %}
                {% for shipment in shipments %}
                    {% if shipments|length > 1 %}
                        <h3>{{ shipment.delivery_type.name }}</h3>
                        {% if shipment.delivery_type.checkout_description %}
                            <p>{{ shipment.delivery_type.checkout_description }}</p>
                        {% endif %}
                        <p>{{ lex('commerce.shipping.multiple_shipments.products') }}</p>
                        <ul>
                            {% for item in shipment.items %}
                                <li>{{ item.quantity }}x {{ item.name }}</li>
                            {% endfor %}
                        </ul>
                    {% else %}
                        {% if shipment.delivery_type.checkout_description %}
                            <p>{{ shipment.delivery_type.checkout_description }}</p>
                        {% endif %}
                    {% endif %}
                    {% if shipment.method.id %}
                        <p>Chosen shipping method <b>{{ shipment.method.name }}</b></p>
                    {% else %}
                        <p><em>Please enter your address first.</em></p>
                    {% endif %}
                {% else %}
                    <p><em>Please enter your address first.</em></p>
                {% endfor %}
            {% endblock %}
        </div>
        <div style="flex: 1;">
            <h2>Payment</h2>
            {% block payment %}
                <p><em>Please choose a shipping method first.</em></p>
            {% endblock %}
        </div>
    </div>
</div>

There is some room for improvement in this template, mostly in how existing order information is shown. It would be nice to add a “Modify” button for example near the address, which allows the customer to “go back”. That button would just need to be a link pointing to checkout?step=address, which the javascript would load via AJAX.

The shipping display could also be nicer, I’d really like to add an estimated shipping day into Commerce for example…

Next, the templates for each individual steps. These are more or less the same as the default, except some of the wrapping divs are removed, and they extend the onepage.twig template while defining/overriding their own blocks.

frontend/checkout/address.twig

{% extends "frontend/checkout/onepage.twig" %}
{% block address %}
    <form method="POST" action="{{ current_url }}">
        <input type="hidden" name="add_address" value="1">

        <h3>{{ lex('commerce.shipping_address') }}</h3>

        {% if previously_used_shipping|length > 0 %}
        <div class="c-checkout-previous-address-list">
            {% for address in previously_used_shipping %}
                {% include "frontend/checkout/partial/previous-address.twig" with {
                address: address,
                type: 'shipping',
                current_address: address_shipping_id
                } %}
            {% endfor %}

            <div class="c-method-wrapper c-shipping-address-wrapper">
                <input type="radio"
                       name="shipping_address"
                       class="c-method-radio c-shipping-address-radio"
                       id="shipping-address-new"
                       value="new"
                       {% if address_shipping_id == 'new' %}checked="checked"{% endif %}
                >
                <div class="c-method-section c-shipping-address-section">
                    <label for="shipping-address-new">
                        {{ lex('commerce.add_new_address') }}
                    </label>
                    <div class="c-method-details">
                        {% endif %}

                        {% include 'frontend/checkout/partial/shipping-address-fields.twig' %}

                        {% if previously_used_shipping|length > 0 %}
                    </div>
                </div>
            </div>
        </div>
        {% endif %}


        <h3>{{ lex('commerce.billing_address') }}</h3>

        <div class="c-method-wrapper c-billing-address-wrapper">
            <input type="radio"
                   name="billing_address"
                   class="c-method-radio c-billing-address-radio"
                   id="billing-address-same"
                   value="same"
                   {% if address_billing_id == 'same' %}checked="checked"{% endif %}
            >
            <div class="c-method-section c-shipping-address-section">
                <label for="billing-address-same">{{ lex('commerce.same_as_shipping') }}</label>
            </div>
        </div>

        {% if previously_used_shipping|length > 0 %}
            <div class="c-checkout-previous-address-list">
                {% for address in previously_used_shipping %}
                    {% include "frontend/checkout/partial/previous-address.twig" with {
                    address: address,
                    type: 'billing',
                    current_address: address_billing_id
                    } %}
                {% endfor %}
            </div>
        {% endif %}

        <div class="c-method-wrapper c-billing-address-wrapper">
            <input type="radio"
                   name="billing_address"
                   class="c-method-radio c-billing-address-radio"
                   id="billing-address-new"
                   value="new"
                   {% if address_billing_id == 'new' %}checked="checked"{% endif %}
            >
            <div class="c-method-section c-shipping-address-section">
                <label for="billing-address-new">{{ lex('commerce.add_new_address') }}</label>
                <div class="c-method-details">
                    {% include 'frontend/checkout/partial/billing-address-fields.twig' %}
                </div>
            </div>
        </div>

        <div class="c-submit">
            <button class="c-button c-primary-button">{{ lex('commerce.checkout_address_confirm') }}</button>
        </div>
    </form>
{% endblock %}

frontend/checkout/shipping-method.twig

{% extends "frontend/checkout/onepage.twig" %}
{% block shipping %}
    <form method="POST" action="{{ current_url }}" >
        <input type="hidden" name="set_shipping_method" value="1">

        {% if shipments|length > 1 %}
            <p>{{ lex('commerce.shipping.multiple_shipments') }}</p>
        {% endif %}

        {#
            Shipments in commerce are basically collections of products of a specific delivery type.
            If there's more than one shipment per order, the customer selects a shipping method for each.
            Learn more here: https://docs.modmore.com/en/Commerce/v1/Delivery_Types.html
        #}
        {% for shipment in set_shipments %}
            {% if shipments|length > 1 %}
                <h3>{{ shipment.delivery_type.name }}</h3>
                {% if shipment.delivery_type.checkout_description %}
                    <p>{{ shipment.delivery_type.checkout_description }}</p>
                {% endif %}
                <p>{{ lex('commerce.shipping.multiple_shipments.products') }}</p>
                <ul>
                    {% for item in shipment.items %}
                        <li>{{ item.quantity }}x {{ item.name }}</li>
                    {% endfor %}
                </ul>
            {% else %}
                {% if shipment.delivery_type.checkout_description %}
                    <p>{{ shipment.delivery_type.checkout_description }}</p>
                {% endif %}
            {% endif %}

            {% for method in shipment.shipping_methods %}
                <div class="c-method-wrapper c-shipping-method-wrapper">
                    <input type="radio"
                           name="shipments[{{ shipment.id }}]"
                           class="c-method-radio c-shipping-method-radio"
                           id="shipping-method-{{ shipment.id }}-{{ method.id }}"
                           value="{{ method.id }}"
                           {% if shipment.method == method.id %}checked="checked"{% endif %}
                    >
                    <div class="c-method-section c-shipping-method-section">
                        <label for="shipping-method-{{ shipment.id }}-{{ method.id }}">
                            {{ method.name }}
                            {% if method.price != 0 %}
                                - {{ method.price_formatted }} ({{ lex('commerce.total') }}: {{ method.total_formatted }})
                            {% endif %}
                        </label>

                        {% if method.description|length > 0 %}
                            <p class="c-method-description c-shipping-method-description">{{ method.description }}</p>
                        {% endif %}

                        {% if method.gateway_form|length > 0 %}
                            <div class="c-method-gateway-form c-shipping-method-gateway-form">
                                {% autoescape false %}
                                    {{ method.gateway_form }}
                                {% endautoescape %}
                            </div>
                        {% endif %}
                    </div>
                </div>
            {% endfor %}
        {% endfor %}

        <button type="submit" class="c-button c-primary-button">{{ lex('commerce.checkout_shippingmethod_confirm') }}</button>
    </form>
{% endblock %}

frontend/checkout/payment-method.twig

{% extends "frontend/checkout/onepage.twig" %}
{% block payment %}
    <p>{{ lex('commerce.checkout_payment_summary', {'total': order.total_formatted}) }}</p>

    <form method="POST" action="{{ current_url }}" class="c-choose-payment-form" id="c-choose-payment-form">
        <input type="hidden" name="choose_payment_method" value="0">

        {% if payment_methods|length > 0 %}
            {% for method in payment_methods %}
                <div class="c-method-wrapper c-payment-method-wrapper">
                    <input type="radio" name="choose_payment_method" class="c-method-radio c-payment-method-radio" id="payment-method-{{ method.id }}" value="{{ method.id }}">
                    <div class="c-method-section c-payment-method-section">
                        <label for="payment-method-{{ method.id }}" tabindex="0">
                            {{ method.name }}
                            {% if method.price != 0 %}
                                - {{ method.price_formatted }} ({{ lex('commerce.total') }}: {{ method.total_formatted }})
                            {% endif %}
                        </label>

                        {% if method.description|length > 0 %}
                            <p class="c-method-description c-payment-method-description">{{ method.description }}</p>
                        {% endif %}

                        {% if method.gateway_form|length > 0 %}
                            <div class="c-method-gateway-form c-payment-method-gateway-form">
                                {% autoescape false %}
                                    {{ method.gateway_form }}
                                {% endautoescape %}
                            </div>
                        {% endif %}
                    </div>
                </div>
            {% endfor %}
        {% endif %}

        <button type="submit" class="c-button c-primary-button">{{ lex('commerce.checkout_payment_confirm') }}</button>
    </form>
{% endblock %}

Possible improvements

You may also want to do something similar with the other checkout-related templates, like pending-transaction.twig (if you have payment methods that are slow/asynchronous to confirm, like bank transfer/bitcoin/others), thank-you.twig (for the thank you page) and account.twig (for login/registration before the checkout). I didn’t add those, but then again I spent longer writing this post than I did making it look nice :wink:

The JavaScript could also be improved further. To allow back/previous navigation, implementing the HTML5 History APIs for example. Disabling buttons and showing a spinner while an ajax request is happening would make it more user friendly as well.

I’d love to see what amazing things you can come up with, so please do share if you made something really amazing based on this :slight_smile:


(Mark Hamstra) #2

Since Commerce 0.10.5, POST-style redirects are also supported to account for certain payment gateways (Adyen, for one).

To make this work with this one-page checkout, the javascript needs to be updated a bit to check the result.redirect_method and result.redirect_data.

    $(function() {
        var messages = $('.c-messages'),
            stepWrapper = $('.c-step-wrapper');

        $(document).on('submit', '.c-step-wrapper form', function(e) {
            e.preventDefault();

            var form = $(this),
                action = form.attr('action'),
                values = form.serialize();

            $.ajax({
                url: action,
                data: values,
                dataType: 'json',
                success: updateFromResponse
            });
        });

        $(document).on('click', '.c-steps-indicator a', function(e) {
            e.preventDefault();

            var link = $(this),
                href = link.attr('href'),
                indicators = $('.c-steps-indicator .active');
            indicators.each(function(i, item) {
                $(item).removeClass('active');
            });
            link.parent().addClass('active');

            $.ajax({
                url: href,
                dataType: 'json',
                success: updateFromResponse
            });


        });

        function updateFromResponse(result) {
            // Update the message holder
            if (result.message && result.message.length > 0) {
                messages.html(result.message);
            }
            else {
                messages.html('');
            }

            if (result.success) {
                stepWrapper.html(result.output);

				// If we have an off-site redirect, redirect the user
				// works best with link_tag_scheme set to full
				if (result.redirect) {
					// Account for GET or POST-style redirects
					if (result.redirect_method == 'GET') {
						if (result.redirect.substring(0, location.origin.length) !== location.origin) {
							window.location = result.redirect;
						}
						else {
							$.ajax({
								url: result.redirect,
								dataType: 'json',
								success: updateFromResponse
							});
						}
					}
					// For POST redirects (i.e. payment gateways), create a dynamic form with the redirect_data and submit it
					else if (result.redirect_method == 'POST') {
						var form = $("<form />");
						form.attr('action', result.redirect);
						form.attr('method', 'POST');

						$.each(result.redirect_data, function(index, value) {
							var input = $("<input />");
							input.attr('type', 'hidden');
							input.attr('name', index);
							input.attr('value', value);
							form.append(input);
						});

						$('body').append(form);
						form.submit();
					}
				}
            }
            else {
                alert('Had a failure');
            }
        }
    });
```

(Mark Hamstra) #3