allBlogsList

Showing Recently Viewed Products in Sitecore with SXA Part 2


In our last post we created a server-side component to store and display recently viewed products in Sitecore. This quick approach got the job done, but doesn’t quite line up with the other SXA components, which use client-side rendering with Knockout JS. In this post we’ll create this same component using an approach more aligned with the other components in SXA.

One of the reasons to take this approach is to align with the design, layout, and style offered by the other SXA components already in use in the site. For example, here’s what the Promoted Products component looks like on the homepage:

SXA-Promoted-Laptops

This layout makes perfect sense for reuse by our Recently Viewed Products component, so much so that it makes sense to use this Promoted Products as a starting point. First, we can duplicate and modify the View file, located by default at the path: /Views/Commerce/Catalog/PromotedProducts.cshtml.

For simplicity, we’ll remove some of the features like Error Message, and of course we’ll need a custom model which we’ll look at next.

Here’s the complete markup for our View:

@using Sitecore.XA.Foundation.MarkupDecorator.Extensions
@using Sitecore.XA.Foundation.SitecoreExtensions.Extensions
@using Sitecore.Commerce.XA.Feature.Catalog.Models
@using Sitecore.Mvc

@model CSDemo9.Models.Catalog.RecentlyViewedProductsViewModel

<div class="component container container-clean-background">
    <div class="component-content">

        <div class="component cxa-promoted-products-component" data-bind="style: {display: 'block'}" style="display: none" @Html.Sxa().Component("cxa-recently-viewed-products-component") data-cxa-component-class="RecentlyViewedProducts" data-cxa-component-initialized="false" data-cxa-component-type="component">
            <input type="hidden" name="currentCatalogItemId" value="@Model.CurrentCatalogItemId" data-bind="value: currentCatalogItemId" />
            <input type="hidden" name="maxPageSize" value="@Model.MaxPageSize" data-bind="value: maxPageSize" />

            <div class="product-list component-content">
                <span class="title" data-bind="text: listTitle"></span>
                <ul data-bind="foreach: productsList">
                    <li>
                        <div class="product-summary">
                            <div class="photo">
                                <a data-bind="attr:{href: Link}">
                                    <img data-bind="attr:{src: SummaryImageUrl, title: DisplayName}" alt="product image" />
                                </a>
                            </div>

                            <div class="product-info">
                                <h4 class="product-title" data-bind="attr:{title: DisplayName}">
                                    <a data-bind="attr:{href: Link}, text: DisplayName"></a>
                                </h4>
                                <!-- ko if: Brand-->
                                <div data-bind="html: Brand" class="product-brand"></div>
                                <!-- /ko-->
                                <!-- ko ifnot: Brand-->
                                <div class="product-brand"> </div>
                                <!-- /ko -->
                                <!-- ko ifnot: IsCategory-->
                                <!-- ko if: DisplayStartingFrom -->
                                <div class="lowest-variant-price" data-bind="html: PriceStartingFromText"></div>
                                <!-- /ko -->
                                <!-- ko if: IsOnSale-->
                                <div class="current-price on-sale" data-bind="text: AdjustedPriceWithCurrency"></div>
                                <div class="previous-price on-sale" data-bind="text: ListPriceWithCurrency"></div>
                                <div class="savings on-sale">
                                    <span class="savings-text" data-bind="html: SavePercentLead"></span>
                                    <span class="savings-percentage" data-bind="text:SavingsPercentage+'%'"></span>
                                </div>
                                <!-- /ko -->
                                <!-- ko ifnot: IsOnSale-->
                                <div class="current-price" data-bind="text: ListPriceWithCurrency"></div>
                                <div class="previous-price"></div>
                                <div class="savings"></div>
                                <div></div>
                                <!-- /ko -->
                                <!-- ko if: StockStatus -->
                                <div data-bind="text: StockStatusLabel, attr:{class: StockStatusName + ' product-stock-status'}"></div>

                                <!-- ko if: StockAvailabilityDate -->
                                <!-- TODO: Style the stock availability date-->
                                @*<div class="product-stock-availability-date" data-bind="text: '/' + StockAvailabilityDate"></div>*@
                                <!-- /ko -->
                                <!-- ko ifnot: StockAvailabilityDate -->
                                <div class="product-stock-availability-date"></div>
                                <!-- /ko -->
                                <!-- /ko -->
                                <!-- ko ifnot: StockStatus -->
                                <div class="product-stock-status"> </div>
                                <div class="price-stock"></div>
                                <!-- /ko -->
                                <!-- ko if:IsOnSale -->
                                <div class="product-category on-sale">
                                    <a data-bind="attr:{href: Link}">
                                        <span class="icon-list"></span><span data-bind="html: ProductPageLinkText"></span>
                                    </a>
                                </div>
                                <!-- /ko -->
                                <!-- ko ifnot: IsOnSale -->
                                <div class="product-category">
                                    <a data-bind="attr:{href: Link}">
                                        <span class="icon-list"></span><span data-bind="html: ProductPageLinkText"></span>
                                    </a>
                                </div>
                                <!-- /ko -->
                                <!-- /ko -->
                                <!-- ko if: IsCategory-->
                                <!-- ko if: IsOnSale-->
                                <div class="product-category on-sale">
                                    <a data-bind="attr: {href: Link}">
                                        <span class="icon-list"></span><span data-bind="html: Category"></span>
                                    </a>
                                </div>
                                <!-- /ko -->
                                <!-- ko ifnot: IsOnSale-->
                                <div class="product-category">
                                    <a data-bind="attr: {href: Link}">
                                        <span class="icon-list"></span><span data-bind="html: Category"></span>
                                    </a>
                                </div>
                                <!-- /ko -->
                                <!-- /ko -->
                            </div>
                        </div>
                    </li>
                </ul>
            </div>
        </div>
    </div>

This uses knockout to bind all the properties retrieved via a client-side call, but also requires that we bind a few server-side properties (like the current product, if any). These are passed via a simple model:

    public class RecentlyViewedProductsViewModel
    {
        public string CurrentCatalogItemId { get; set; }
        public int MaxPageSize { get; set; } = 4;
    }

Now that we have the UI for our component, we just need an action to run it, which we’ll do in a new RecentlyViewedProductsController class. Just like before, we need to inject or retrieve all the related services via the constructor or Service Locator. We also need to make sure to update the currently viewed product (if any) to the product history.

However, in addition to an action to return the view above, we also need an AJAX endpoint to return the JSON containing the product data. This will be called client-side to populate the component. Once again, using the Promoted Products component as a sample, we find that the format expects a cci parameter for the current catalog item, as well as a ps parameter for page size (retrieved from the hidden input fields above from the view, passed from the controller).

Since we used the Promoted Products as a base, we can pretty much reuse everything from that, including the resulting PromotedProductsJsonResult model to populate and return the result. Here is the complete code for the controller that handles all these actions:

    [SessionState(System.Web.SessionState.SessionStateBehavior.ReadOnly)]
    public class RecentProductsController : BaseCommerceStandardController
    {
        ISiteContext siteContext;
        IVisitorContext visitorContext;
        IProductInformationRepository productInfoRepo;
        ISearchManager searchManager;
        IModelProvider modelProvider;
        ICatalogManager catalogManager;

        public RecentProductsController() : base()
        { 
            siteContext = DependencyResolver.Current.GetService();
            this.visitorContext = DependencyResolver.Current.GetService();
            this.productInfoRepo = DependencyResolver.Current.GetService();
            this.productInfoRepo = DependencyResolver.Current.GetService();
            this.StorefrontContext = DependencyResolver.Current.GetService();
            this.modelProvider = DependencyResolver.Current.GetService();
            this.searchManager = DependencyResolver.Current.GetService() ?? new SearchManager(this.StorefrontContext, this.SitecoreContext);
            this.StorefrontContext = DependencyResolver.Current.GetService();
            this.catalogManager = DependencyResolver.Current.GetService();
        }
        
        public ActionResult RecentlyViewedProducts()
        {

            CatalogItemRenderingModel currentProductModel = null;

            // save any currently viewed product to the history
            Item currentCatalogItem = this.siteContext.CurrentCatalogItem;
            if (currentCatalogItem != null)
            {
                currentProductModel = this.productInfoRepo.GetProductInformationRenderingModel(this.visitorContext);
                GetAndUpdateSkuList(currentProductModel, currentCatalogItem);
            }

            return View();
        }

        private List GetAndUpdateSkuList(CatalogItemRenderingModel currentProductModel, Item currentCatalogItem)
        {
            var recentProducts = System.Web.HttpContext.Current.GetCookie(Constants.Products.RecentlyViewedProducts) ?? string.Empty;
            var recentSkus = recentProducts.Split("|".ToCharArray(), StringSplitOptions.RemoveEmptyEntries).ToList();

            if (currentCatalogItem != null && currentProductModel != null)
            {
                if (string.IsNullOrEmpty(recentProducts))
                {
                    recentSkus.Add(currentProductModel.ProductId);
                }
                else
                {
                    if (recentSkus.Contains(currentProductModel.ProductId))
                    {
                        recentSkus.Remove(currentProductModel.ProductId);
                    }

                    recentSkus.Insert(0, currentProductModel.ProductId);
                }

                System.Web.HttpContext.Current.SetCookie(Constants.Products.RecentlyViewedProducts, string.Join("|", recentSkus));
            }

            return recentSkus;
        }
        
        [HttpPost, ValidateHttpPostHandler, ValidateAntiForgeryToken]
        public JsonResult GetRecentlyViewedProducts([Bind(Prefix = "cci")] string currentCatalogItemId, [Bind(Prefix = "ps")] int pageSize = 4)
        {
            var recentProducts = GetRecentProducts(currentCatalogItemId, pageSize);
            return base.Json(recentProducts);
        }

        private PromotedProductsJsonResult GetRecentProducts(string currentCatalogItemId, int pageSize = 4)
        {
            PromotedProductsJsonResult result = new PromotedProductsJsonResult(this.StorefrontContext, this.SitecoreContext);

            var recentProducts = System.Web.HttpContext.Current.GetCookie(Constants.Products.RecentlyViewedProducts) ?? string.Empty;
            var recentSkus = recentProducts.Split("|".ToCharArray(), StringSplitOptions.RemoveEmptyEntries).Where(s => !s.Equals(currentCatalogItemId)).Take(pageSize).ToList();

            var recentProductModels = new List();
            foreach (var sku in recentSkus)
            {
                var productItem = searchManager.GetProduct(sku, this.StorefrontContext.CurrentStorefront.Catalog);
                if (productItem != null)
                {
                    var productEntity = new ProductEntity();
                    productEntity.Initialize(this.StorefrontContext.CurrentStorefront, productItem);
                    catalogManager.GetProductPrice(StorefrontContext.CurrentStorefront, visitorContext, productEntity);

                    var productModel = modelProvider.GetModel();
                    productModel.Initialize(productEntity, false);
                    if (productModel != null && !recentProductModels.Any(p => p.ProductId == productModel.ProductId))
                    {
                        recentProductModels.Add(productModel);
                    }
                }
            }

            result.Initialize("Recently Viewed Products", recentProductModels);

            return result;
        }
    }

Register Scripts

We have all the server-side elements in place, but we still need to issue the client-call to load and bind the data. Once again, looking at the Promoted Products component as an example, we find that there are two scripts loaded from this path: \Scripts\Commerce\Feature\Catalog

  • cxa.feature.promotedproducts.js
  • cxa.feature.promotedproducts.model.js

Duplicating these, we create versions for our component, changing the names and particularly the API endpoint called in the model file. Here’s the code for cxa.feature.recentlyviewedproducts.js:

(function (root, factory) {
    if (typeof define === 'function' && define.amd) {
        // use AMD define funtion to support AMD modules if in use
        define('CXA/Feature/RecentlyViewedProducts', ['exports'], factory);

    } else if (typeof exports === 'object') {
        // to support CommonJS
        factory(exports);
    }
    // browser global variable
    root.RecentlyViewedProducts = factory;
    root.RecentlyViewedProducts_ComponentClass = "cxa-promoted-products-component";

}(this,
    function (element) {
        var component = new Component(element);
        var productListsRawValue = $(component.RootElement).find("[name = productListsRawValue]").val();
        var relationshipId = $(component.RootElement).find("[name = relationshipId]").val();
        var currentItemId = $(component.RootElement).find("[name = currentItemId]").val();
        var currentCatalogItemId = $(component.RootElement).find("[name = currentCatalogItemId]").val();
        var maxPageSize = $(component.RootElement).find("[name = maxPageSize]").val();
        var useLazyLoading = $(component.RootElement).find("[name = useLazyLoading]").val();

        component.model = new RecentlyViewedProductsViewModel();
        component.model.productListsRawValue(productListsRawValue);
        component.model.relationshipId(relationshipId);
        component.model.currentItemId(currentItemId);
        component.model.currentCatalogItemId(currentCatalogItemId);
        component.model.maxPageSize(maxPageSize);
        component.model.useLazyLoading(useLazyLoading);
        component.Name = "CXA/Feature/RecentlyViewedProducts";

        component.InExperienceEditorMode = function() {
        }
        component.Init = function () {
            component.model.loadProducts();
            ko.applyBindings(component.model, component.RootElement);
        };
    return component;
}));

function setEqualHeight(columns) {
    var tallestcolumn = 0;
    columns.each(function () {
        currentHeight = $(this).height();
        if (currentHeight > tallestcolumn) {
            tallestcolumn = currentHeight;
        }
    });
    columns.height(tallestcolumn);
}

$(window).on("load", function () {
    setEqualHeight($(".product-list div.col-sm-4"));
});

And here is the code needed for cxa.feature.recentlyviewedproducts.model.js:

//-----------------------------------------------------------------------
// Copyright 2016 Sitecore Corporation A/S
// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file 
// except in compliance with the License. You may obtain a copy of the License at
//       http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software distributed under the 
// License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 
// either express or implied. See the License for the specific language governing permissions 
// and limitations under the License.
// -------------------------------------------------------------------------------------------

function RecentlyViewedProductsViewModel() {
    var self = this;
    self.recentlyViewedProducts = ko.observableArray();
    self.recentlyViewedProductsTitle = ko.observable();
    self.productsList = ko.observableArray();
    self.listTitle = ko.observable();
    self.useLazyLoading = ko.observable(false);
    self.maxPageSize = ko.observable();
    self.pageNumber = ko.observable(0);
    self.currentItemId = ko.observable();
    self.currentCatalogItemId = ko.observable();
    self.productListsRawValue = ko.observable();
    self.relationshipId = ko.observable();
    self.canLoadMoreProducts = ko.observable(false);

    self.loadProducts = function () {
        var params = self.loadingParameters();
        
         if (CXAApplication.IsExperienceEditorMode()) {
             var data = getMockData();
             addProductList(data);
         } else {
             AjaxService.Post("/csdemo9/api/RecentProducts/GetRecentlyViewedProducts", params, function (data, success, sender) {
                 if (success && data && data.Success) {
                     addProductList(data);
                 }
             });
         }
    }

    self.loadingParameters = function () {

        var params = {};

        // Page number
        params.pg = getPageNumber();

        // Page size
        params.ps = self.maxPageSize() || 4;

        // Current Item Id
        params.ci = self.currentItemId();

        // Current catalog Item Id
        params.cci = self.currentCatalogItemId();

        // Promoted productLists rendering parameter value
        params.plrv = self.productListsRawValue();

        // Relationship type field Id rendering parameter value
        params.rt = self.relationshipId();

        return params;
    }

    function addProductList(data) {
        $(data.ProductsList).each(function () {
            self.productsList.push(this);
        });

        self.listTitle(data.ListTitle);
        self.canLoadMoreProducts(data.ProductsList && (data.ProductsList.length >= (self.maxPageSize() || 4)));
    };

    function getPageNumber() {
        var pageNumber = self.pageNumber();
        // Increment the page number so that the next call for loadMoreProducts will load the next page
        self.pageNumber(self.pageNumber() + 1);
        return pageNumber;
    }

    function getMockData() {
        var productPerPage = self.maxPageSize() || 4;
        var mockData = { ProductsList: [] };
        var mockProductImage = getProductMockImage();
        var PDPLink = getPDPLink();
        var mockProductModel = {
            "CatalogName": null,
            "DisplayName": "Lorem ipsum",
            "Features": null,
            "Description": null,
            "IsCategory": false,
            "ParentCategoryId": null,
            "ParentCategoryName": null,
            "SummaryImageUrl":mockProductImage,
            "Link": PDPLink,
            "ProductId": "12345",
            "CurrencySymbol": "USD",
            "CustomerAverageRating": 3,
            "IsOnSale": true,
            "ListPrice": 14.95,
            "AdjustedPrice": 12.95,
            "AdjustedPriceWithCurrency": "12.95 USD",
            "ListPriceWithCurrency": "14.95 USD",
            "LowestPricedVariantAdjustedPrice": 12.95,
            "LowestPricedVariantAdjustedPriceWithCurrency": "12.95 USD",
            "LowestPricedVariantListPrice": 12.85,
            "LowestPricedVariantListPriceWithCurrency": "12.85 USD",
            "HighestPricedVariantAdjustedPrice": 15.95,
            "VariantSavingsPercentage": 0,
            "SavingsPercentage": 13,
            "Quantity": null,
            "StockStatus": { "Value": 1, "Name": "InStock" },
            "StockAvailabilityDate": null,
            "StockStatusName": "In-Stock",
            "StockStatusLabel": "In Stock",
            "DisplayStartingFrom": false,
            "PriceStartingFromText": "Starting from",
            "ProductPageLinkText": "Details",
            "SavePercentLead": "Save up to",
            "Category": "Category",
            "Brand": "",
            "IsVariant": false,
            "Variants": null,
            "VariantDefinitions": null,
            "GiftCardAmountOptions": null
        };

        for (var product = 1; product <= productperpage;="" product++)="" {="" mockdata.productslist.push(mockproductmodel);="" }="" return="" mockdata;="" }="" function="" getproductmockimage()="" {="" var="" imagesrc="" ;="" var="" pageextension="getCurrentPageExtension();" if="" (pageextension="==" "html")="" {="" imagesrc="-/media/Feature/Experience Accelerator/Commerce/Catalog/200x300.png" ;="" }="" else="" {="" imagesrc="/sitecore/shell/-/media/Feature/Experience-Accelerator/Commerce/Catalog/200x300.png" ;="" }="" return="" imagesrc;="" }="" function="" getpdplink()="" {="" var="" link="" ;="" var="" pageextension="getCurrentPageExtension();" if="" (pageextension="==" "html")="" {="" link="Shop/_/_/index.html" ;="" }="" else="" {="" link="/" ;="" }="" return="" link;="" }="" }="">

The last thing we need to do is tell Sitecore to wire these scripts up so they’re loaded and called when needed by the component. This is done by adding a config file, which we’ll put in this path: \App_Config\Include\Feature\CommerceSitecore.Commerce.XA.Feature.Catalog.RecentlyViewedProducts.config:

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <commerce.XA>
      <javascript>
        <feature>
          <file name="CxaRecentlyViewedProducts" path="/Scripts/Commerce/Feature/Catalog/cxa.feature.recentlyviewedproducts.js" order="217"/>
          <file name="CxaRecentlyViewedProductsModel" path="/Scripts/Commerce/Feature/Catalog/cxa.feature.recentlyviewedproducts.model.js" order="216"/>
        </feature>
      </javascript>
    </commerce.XA>
  </sitecore>
</configuration>


Now we simply publish and register our component, and after browsing a few products, we see a layout that perfectly matches the rest of the SXA site:

Recently-Viewed-Products-Client-Side

Wrapping Up

SXA not only provides useful functionality out of the box, it also serves as an example and even a starting point for other components your site may need. By modifying and extending these to create new components you can quickly and easily add value to your Sitecore site.

As always, thank you for reading, and I hope this was helpful!