How to Add a New Additional Step In Checkout Page Magento 2?

By Default Magento Checkout has 2 steps coming out of the box in Magento 2. The Shipping Step and the Payment and review step.

For an eCommerce site with additional business logic, you may want to add some additional logic like displaying the Checkout summary to Review Step.

You need to add a new step to display the Checkout summary to the final step.

In Magento 2 Customization checkout is a little bit difficult because the new concept of knockout js and other JS stuff related to checkout requires developers to more effort into customizing it.

This topic will guide you to create a new step checkout in Magento 2 in a simple way. I have created a simple module for it to add a new Review step.

For example, add a new Review & Place Order Step after the Review & Payments steps.

Create a Module called Rbj_Checkout. I have kept my module name Rbj_Checkout for the sake of simplicity.
Create registration.php and module.xml to declare our module.

File Path, app/code/Rbj/Checkout/registration.php

<?php
use Magento\Framework\Component\ComponentRegistrar;

ComponentRegistrar::register(
    ComponentRegistrar::MODULE,
    'Rbj_Checkout',
    __DIR__
);

File Path, app/code/Rbj/Checkout/etc/module.xml, We have added the dependency on the native checkout module.

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
    <module name="Rbj_Checkout">
        <sequence>
            <module name="Magento_Checkout"/>
        </sequence>
    </module>
</config>

app/code/Rbj/Checkout/view/frontend/layout/checkout_index_index.xml Declare our Review step js and html file from checkout_index_index.xml file.

<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" layout="1column" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
    <body>
        <referenceBlock name="checkout.root">
            <arguments>
                <argument name="jsLayout" xsi:type="array">
                    <item name="components" xsi:type="array">
                        <item name="checkout" xsi:type="array">
                            <item name="children" xsi:type="array">
                                <item name="steps" xsi:type="array">
                                    <item name="children" xsi:type="array">
                                        <!-- The new step you add for last step -->
                                        <item name="reviewstep" xsi:type="array">
                                            <item name="component" xsi:type="string">Rbj_Checkout/js/view/review-step</item>
                                            <item name="sortOrder" xsi:type="string">3</item>
                                            <item name="children" xsi:type="array">
                                                <!--add here child component declaration for your step-->
                                            </item>
                                        </item>
                                    </item>
                                </item>
                            </item>
                        </item>
                    </item>
                </argument>
            </arguments>
        </referenceBlock>
    </body>
</page>

In the Checkout XML file,

    • <item name="component" xsi:type="string">Rbj_Checkout/js/view/review-step</item> define our js file to add a new step in Checkout.
    • We have added the sortOrder value set to 3 because shipping-step has sortOrder to 1 and billing-step has the sortOrder set to 2.

Now we need to create a review-step.js file and define our new step definition from the JS file.
app/code/Rbj/Checkout/view/frontend/web/js/view/review-step.js

define(
    [
        'jquery',
        'ko',
        'uiComponent',
        'underscore',
        'Magento_Checkout/js/model/step-navigator',
        'mage/translate'
    ],
    function (
        $,
        ko,
        Component,
        _,
        stepNavigator,
        $t
    ) {
        'use strict';
        return Component.extend({
            defaults: {
                template: 'Rbj_Checkout/reviewstep/reviewinfo'
            },

            //add here your logic to display step,
            isVisible: ko.observable(false),
            //isCustomerLoggedIn: customer.isLoggedIn,
            //totals: quote.getTotals(),
            /**
             *
             * @returns {*}
             */
            initialize: function () {
                this._super();
                // register your step
                stepNavigator.registerStep(
                    'review_info', //step_code will be used as component template html file <li> id
                    null,
                    $t('Review & Place Order'),
                    //observable property with logic when display step or hide step
                    this.isVisible,
                    //navigate function call from below
                    _.bind(this.navigate, this),
                    /**
                     * sort order value
                     * 'sort order value' < 10: step displays before shipping step (first step);
                     * 10 < 'sort order value' < 20 : step displays between shipping and payment step
                     * 'sort order value' > 20 : step displays after payment step at the end.(last step)
                     */
                    30
                );
                return this;
            },

            /**
             * The navigate() method is responsible for navigation between checkout step
             * during checkout. You can add custom logic, for example some conditions
             * for switching to your custom step
             *
             * when directly refresh page with #review_info code below function call
             */
            navigate: function () {
                var self = this;
                self.isVisible(true);
                //window.location = window.checkoutConfig.checkoutUrl + "#payment";
            },

            /**
             * @returns void
             */
            navigateToNextStep: function () {
                stepNavigator.next();
            },

            placeorder: function(){
                console.log('revieworder');
                $('.payment-method._active .primary button.checkout').trigger('click');
            },

            back: function() {
                stepNavigator.navigateTo('shipping');
            },
            backbilling: function() {
                $('.payment-method._active .primary button.checkout').removeClass("disabled");
                stepNavigator.navigateTo('payment');
            },
        });
    }
);

With the use of .html a file to display data in the additional checkout step
app/code/Rbj/Checkout/view/frontend/web/template/reviewstep/reviewinfo.html

<!-- Here id review_info is same as review-step.js step_code -->
<li id="review_info" role="presentation" class="checkout-review-method" data-bind="fadeVisible: isVisible">
    <div class="actions-toolbar">
        <div class="primary">
            <button type="submit"
                    data-bind="
                    attr: {title: $t('Place Order')},
                    click: placeorder
                    "
                    class="action primary checkout-review"
            >
                <span data-bind="i18n: 'Place Order'"></span>
            </button>
        </div>
    </div>
    <div class="checkout-agreements-block">
        <!-- ko foreach: getRegion('before-place-order') -->
        <!-- ko template: getTemplate() --><!-- /ko -->
        <!--/ko-->
    </div>
</li>

HTML file used to display content on our additional steps. This is used to show html structures on the new steps and you can add all the html related code to the file.

Using the above step we can add the new step in the checkout page and for place order we need to change the custom logic of step 2, We need to set the place order button logic from step 3 now so we have changed place-order.js and we need to also change custom logic in placeOrder() function from default.js

We need to create webapi.xml to change the business logic of the place order button on step 2, Using webapi.xml when a user clicks on the place order button they will save current payment information and not a place order on step 2. When we click on the place order button on step 3 they will place the order and complete the checkout flow.

Using Custom /V1/guest-carts/:cartId/payment-information-custom and /V1/carts/mine/payment-information-custom request we will save only payment info from step 2.

File Path, app/code/Rbj/Checkout/etc/webapi.xml

<?xml version="1.0"?>
<routes xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Webapi:etc/webapi.xsd">
    <!-- Managing payment guest information -->
    <route url="/V1/guest-carts/:cartId/payment-information-custom" method="POST">
        <service class="Magento\Checkout\Api\GuestPaymentInformationManagementInterface" method="savePaymentInformation"/>
        <resources>
            <resource ref="anonymous" />
        </resources>
    </route>
    <!-- Managing My payment information -->
    <route url="/V1/carts/mine/payment-information-custom" method="POST">
        <service class="Magento\Checkout\Api\PaymentInformationManagementInterface" method="savePaymentInformation"/>
        <resources>
            <resource ref="self" />
        </resources>
        <data>
            <parameter name="cartId" force="true">%cart_id%</parameter>
        </data>
    </route>
</routes>

We need to override the core js file and so we need to create requirejs-config.js file, Path, app/code/Rbj/Checkout/view/frontend/requirejs-config.js

var config = {
    map: {
        '*': {
            "Magento_Checkout/js/action/place-order" : 'Rbj_Checkout/js/action/place-order',
            "Magento_Checkout/js/view/payment/default" : "Rbj_Checkout/js/view/payment/default"
        }
    }
};

For Change Payment save logic, Overrider place-order.js  file.

The file path, app/code/Rbj/Checkout/view/frontend/web/js/action/place-order.js

define([
    'Magento_Checkout/js/model/quote',
    'Magento_Checkout/js/model/url-builder',
    'Magento_Customer/js/model/customer',
    'Magento_Checkout/js/model/place-order',
    'Magento_Checkout/js/model/step-navigator'
], function (quote, urlBuilder, customer, placeOrderService,stepNavigator) {
    'use strict';

    return function (paymentData, messageContainer) {
        var serviceUrl, payload;

        payload = {
            cartId: quote.getQuoteId(),
            billingAddress: quote.billingAddress(),
            paymentMethod: paymentData
        };

        if (customer.isLoggedIn()) {
            if(stepNavigator.getActiveItemIndex() == 1) {
                /* from payment step only save payment */
                serviceUrl = urlBuilder.createUrl('/carts/mine/payment-information-custom', {});
            } else{
                /* from review step only place order of save payment */
                serviceUrl = urlBuilder.createUrl('/carts/mine/payment-information', {});
            }
        } else {
            if(stepNavigator.getActiveItemIndex() == 1) {
                serviceUrl = urlBuilder.createUrl('/guest-carts/:quoteId/payment-information-custom', {
                    quoteId: quote.getQuoteId()
                });
            } else{
                serviceUrl = urlBuilder.createUrl('/guest-carts/:quoteId/payment-information', {
                    quoteId: quote.getQuoteId()
                });
            }
            payload.email = quote.guestEmail;
        }

        return placeOrderService(serviceUrl, payload, messageContainer);
    };
});

Final default.js file override for new step custom logic of place order on click of place order and other stuff using below file.

Path:app/code/Rbj/Checkout/view/frontend/web/js/view/payment/default.js

define([
    'ko',
    'jquery',
    'uiComponent',
    'Magento_Checkout/js/action/place-order',
    'Magento_Checkout/js/action/select-payment-method',
    'Magento_Checkout/js/model/quote',
    'Magento_Customer/js/model/customer',
    'Magento_Checkout/js/model/payment-service',
    'Magento_Checkout/js/checkout-data',
    'Magento_Checkout/js/model/checkout-data-resolver',
    'uiRegistry',
    'Magento_Checkout/js/model/payment/additional-validators',
    'Magento_Ui/js/model/messages',
    'uiLayout',
    'Magento_Checkout/js/action/redirect-on-success',
    'Magento_Checkout/js/model/step-navigator',
    'Magento_Checkout/js/model/full-screen-loader'
], function (
    ko,
    $,
    Component,
    placeOrderAction,
    selectPaymentMethodAction,
    quote,
    customer,
    paymentService,
    checkoutData,
    checkoutDataResolver,
    registry,
    additionalValidators,
    Messages,
    layout,
    redirectOnSuccessAction,
    stepNavigator,
    fullScreenLoader
) {
    'use strict';

    return Component.extend({
        redirectAfterPlaceOrder: true,
        isPlaceOrderActionAllowed: ko.observable(quote.billingAddress() != null),

        /**
         * After place order callback
         */
        afterPlaceOrder: function () {
            // Override this function and put after place order logic here
        },

        /**
         * Initialize view.
         *
         * @return {exports}
         */
        initialize: function () {
            var billingAddressCode,
                billingAddressData,
                defaultAddressData;

            this._super().initChildren();
            quote.billingAddress.subscribe(function (address) {
                this.isPlaceOrderActionAllowed(address !== null);
            }, this);
            checkoutDataResolver.resolveBillingAddress();

            billingAddressCode = 'billingAddress' + this.getCode();
            registry.async('checkoutProvider')(function (checkoutProvider) {
                defaultAddressData = checkoutProvider.get(billingAddressCode);

                if (defaultAddressData === undefined) {
                    // Skip if payment does not have a billing address form
                    return;
                }
                billingAddressData = checkoutData.getBillingAddressFromData();

                if (billingAddressData) {
                    checkoutProvider.set(
                        billingAddressCode,
                        $.extend(true, {}, defaultAddressData, billingAddressData)
                    );
                }
                checkoutProvider.on(billingAddressCode, function (providerBillingAddressData) {
                    checkoutData.setBillingAddressFromData(providerBillingAddressData);
                }, billingAddressCode);
            });

            return this;
        },

        /**
         * Initialize child elements
         *
         * @returns {Component} Chainable.
         */
        initChildren: function () {
            this.messageContainer = new Messages();
            this.createMessagesComponent();

            return this;
        },

        /**
         * Create child message renderer component
         *
         * @returns {Component} Chainable.
         */
        createMessagesComponent: function () {

            var messagesComponent = {
                parent: this.name,
                name: this.name + '.messages',
                displayArea: 'messages',
                component: 'Magento_Ui/js/view/messages',
                config: {
                    messageContainer: this.messageContainer
                }
            };

            layout([messagesComponent]);

            return this;
        },

        /**
         * Place order.
         */
        placeOrder: function (data, event) {
            var self = this;
            if (event) {
                event.preventDefault();
            }
            if(stepNavigator.getActiveItemIndex() == 1) {
                if (this.validate() && additionalValidators.validate()) {
                    this.isPlaceOrderActionAllowed(false);

                    this.getPlaceOrderDeferredObject()
                        .fail(
                            function () {
                                self.isPlaceOrderActionAllowed(true);
                            }
                        ).done(
                        function () {
                            self.isPlaceOrderActionAllowed(false);
                            fullScreenLoader.stopLoader();
                            $(".action.primary.checkout").removeClass('disabled');
                            stepNavigator.next();
                        }
                    );

                    return true;
                }
            } else {
                if (this.validate()) {
                    this.isPlaceOrderActionAllowed(false);

                    this.getPlaceOrderDeferredObject()
                        .fail(
                            function () {
                                self.isPlaceOrderActionAllowed(true);
                            }
                        ).done(
                        function () {
                            self.afterPlaceOrder();

                            if (self.redirectAfterPlaceOrder) {
                                redirectOnSuccessAction.execute();
                            }
                        }
                    );

                    return true;
                }
            }
            return false;
        },

        /**
         * @return {*}
         */
        getPlaceOrderDeferredObject: function () {
            return $.when(
                placeOrderAction(this.getData(), this.messageContainer)
            );
        },

        /**
         * @return {Boolean}
         */
        selectPaymentMethod: function () {
            selectPaymentMethodAction(this.getData());
            checkoutData.setSelectedPaymentMethod(this.item.method);

            return true;
        },

        isChecked: ko.computed(function () {
            return quote.paymentMethod() ? quote.paymentMethod().method : null;
        }),

        isRadioButtonVisible: ko.computed(function () {
            return paymentService.getAvailablePaymentMethods().length !== 1;
        }),

        /**
         * Get payment method data
         */
        getData: function () {
            return {
                'method': this.item.method,
                'po_number': null,
                'additional_data': null
            };
        },

        /**
         * Get payment method type.
         */
        getTitle: function () {
            return this.item.title;
        },

        /**
         * Get payment method code.
         */
        getCode: function () {
            return this.item.method;
        },

        /**
         * @return {Boolean}
         */
        validate: function () {
            return true;
        },

        /**
         * @return {String}
         */
        getBillingAddressFormName: function () {
            return 'billing-address-form-' + this.item.method;
        },

        /**
         * Dispose billing address subscriptions
         */
        disposeSubscriptions: function () {
            // dispose all active subscriptions
            var billingAddressCode = 'billingAddress' + this.getCode();

            registry.async('checkoutProvider')(function (checkoutProvider) {
                checkoutProvider.off(billingAddressCode);
            });
        }
    });
});
Add new checkout step Magento 2