How to add a new step in checkout page Magento 2?

For Native Magento 2 checkout’s flow contains 2 steps in Magento 2. The Shipping Step and the Payment & Review step. For eCommerce site business logic, you may want to add some additional logic like display Checkout summary to Review Step. You need to add a new step for display Checkout summary to final step.

In Magento 2 Customization checkout is little bit difficult because newly concept of knockout js and other js stuff related to checkout required developer need to more effort to customize it.

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

Example, add new Review & Place Order Step after Review & Payments steps.

Create Module called Rbj_Checkout. I have keep my module name Rbj_Checkout for simplicity.
Create registration.php and module.xml to declare our module.

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

<?php
\Magento\Framework\Component\ComponentRegistrar::register(
    \Magento\Framework\Component\ComponentRegistrar::MODULE,
    'Rbj_Checkout',
    __DIR__
);

File Path, app/code/Rbj/Checkout/etc/module.xml , We have added the dependency on 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" setup_version="1.0.0">
        <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 Checkout xml file, <item name=”component” xsi:type=”string”>Rbj_Checkout/js/view/review-step</item> defined our js file to add new step in Checkout.
Now we need to create review-step.js file and define our new step definition from 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');
            },
        });
    }
);

Our New Step html file for display data in 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>

Using above step we can add the new step in checkout page and for place order we need to change the custom logic of step 2, We need to set 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 for change business logic of place order button on step 2, Using webapi.xml when a user clicks on place order button they will save current payment information and not a place order on step 2. When we click on 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 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.

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