Implementing Customers Also Bought In ASP.NET Web Pages

Most successful e-commerce sites implement a mechanism that draws the customer's attention to other products that might be of interest to them. These products are selected on the basis that shoppers who purchased the item currently being viewed by the customer also bought other products. This article looks at a away of implementing this kind of facility in an ASP.NET Web Pages site.

The key to implementing this feature is the way in which you store the details of orders. The Northwind sample database provides an excellent example that shows how to do this. Orders are stored in the Orders table, which links an order with a customer. The details of the order are stored in a separate table - one row per order item - called OrderDetails:

Order Details Table

Each order item is related to its parent order via the OrderId value, and each the product that is ordered is related to its entry in the Products table via the ProductId value. Note that the UnitPrice is also stored in the OrderDetails table even though it is already present in the Products table. Why, then, should it be stored with the order line? Well, if you increase the price in the Products table, you do not want the value of historical orders to be affected, so you store the price that the item was sold for at the time it was sold as part of the order line entry.

Now that you are storing order details in this way, it is possible to obtain all orders for a specific product via SQL:

SELECT OrderID From OrderDetails WHERE ProductID = 1

And it is also possible to obtain a list of all other products that featured in those orders:

SELECT DISTINCT ProductName FROM OrderDetails 
INNER JOIN Products ON OrderDetails.ProductID = Products.ProductID
WHERE OrderID IN (
    SELECT OrderID FROM OrderDetails WHERE ProductID = 1
) 
AND OrderDetails.ProductID <> 1

But what you really want is the products that featured most often in those orders. So instead of using DISTINCT to weed out duplicate values, you count the number of times each product appears and use that value to order the result, then take the first 5:

SELECT TOP 5 ProductName, COUNT(ProductName) AS CountOfProducts FROM OrderDetails 
INNER JOIN Products ON OrderDetails.ProductID = Products.ProductID
WHERE OrderID IN (
    SELECT OrderID FROM OrderDetails WHERE ProductID = 1
) 
AND OrderDetails.ProductID <> 1 
GROUP BY ProductName ORDER BY CountOfProducts DESC

Here's how it all goes together in a sample app to illustrate how this works. The sample application presents all the product categories in a dropdown list.

View categories

When the user selects a category, an AJAX request retrieves all the products within that category which are returned in a grid.

Grid

Each product features its own button. When it is clicked, the user is presented with the 5 most popular other products that featured in orders that included the selected product:

also bought

First, the _Layout,cshtml file:

@{
    
}

<!DOCTYPE html>

<html lang="en">
    <head>
        <meta charset="utf-8" />
        <title>@Page.Title</title>
        <link href="~/Content/Site.css" rel="stylesheet" type="text/css" />
        <script src="~/Scripts/jquery-1.9.1.min.js" type="text/javascript"></script>
        @RenderSection("script", false)
    </head>
    <body>
        <div id="wrapper">
            @RenderBody()
        </div>
    </body>
</html>

Note that it references jQuery. This is used to manage the AJAX requests and to update the DOM with the response. Here's the main page (Default.cshtml):

@{
    Page.Title = "Home";
    var db = Database.Open("Northwind");
    var data = db.Query("SELECT CategoryID, CategoryName FROM Categories");
    var categories = data.Select( c => new SelectListItem {
            Value = c.CategoryId.ToString(),
            Text = c.CategoryName
        });
}

<div id="selector">
    @Html.Label("Select Category: ", "categoryId")
    @Html.DropDownList("categoryId", "Choose Category", categories)
</div>
<div id="grid"></div>
<div id="alsoBought"></div>
@section script{
    <script>
        $(function () {
            $('#categoryId').change(function () {
                $('#alsoBought').empty();
                $('#grid').load('/Products/' + $(this).val());
            });
            $(document).on('click', '.alsoBought', function () {
                $('#alsoBought').load('AlsoBought/' + $(this).attr('id'));
            })
        });
    </script>
}

The dropdown is populated when the page loads. An event handler is registered which fires when the dropdown's selection changes. It clears the "alsoBought" div of any content and loads the "grid" div with the response from Products.cshtml:

@{
    Layout = null;
    var categoryId = UrlData[0].IsInt() ? UrlData[0].AsInt() : 1;
    var db = Database.Open("Northwind");
    var commandText = @"SELECT ProductId, ProductName, QuantityPerUnit, UnitPrice FROM Products 
                        WHERE CategoryID = @0";
    var data = db.Query(commandText, categoryId);
    var grid = new WebGrid(data, canPage: false, canSort: false);
}

@grid.GetHtml(tableStyle : "table",
    columns: grid.Columns(
                grid.Column("ProductName", "Product"),
                grid.Column("QuantityPerUnit", "Per Unit"),
                grid.Column("UnitPrice", "Unit Price"),
                grid.Column("", format: @<button class="alsoBought" id="@item.ProductId">Also bought?</button>)
            )
)

This file is responsible solely for generating an HTML table populated with the product details. Therefore the Layout property at the top of the file is set to null to prevent any other HTML from getting in the way. A default value of 1 is used for the Category ID value to prevent errors if the page is requested without a valid value being passed in the URL. Event handlers are wired up the last column's buttons in the home page. They fire when the buttons are clicked. They request AlsoBought.cshtml, passing in the product ID as a UrlData value:

@{
    Layout = null;
    var productId = UrlData[0].IsInt() ? UrlData[0].AsInt() : 1;
    var db = Database.Open("Northwind");
    var commandText = @"SELECT TOP 5 ProductName, COUNT(ProductName) AS CountOfProducts FROM OrderDetails 
                        INNER JOIN Products ON OrderDetails.ProductID = Products.ProductID
                        WHERE OrderID IN (
                            SELECT OrderID FROM OrderDetails WHERE ProductID = @0
                        ) 
                        AND OrderDetails.ProductID <> @0 
                        GROUP BY ProductName ORDER BY CountOfProducts DESC";
    var products = db.Query(commandText, productId);
}

<h3>Customers who bought this item also bought</h3>
@foreach(var product in products){
    <div>@product.ProductName</div>
}

AlsoBought.cshtml has its layout property set to null to prevent any stray HTML from interfering with the response. It also features the same default value for the product ID value as the Products.cshtml page as a precaution. Then, using the SQL that was discussed earlier, the 5 most popular products are obtained and then rendered. The response is displayed in the "alsoBought" div by jQuery.

There is no reason why you couldn't extend this pattern to cover other activities, such as blog articles read for example. You can store the articles read by a visitor during a single session in a similar way to order lines and apply the same logic to retrieving those that seem related to the current one.

The sample application is available as a download from GitHub.

Date Posted: Thursday, August 1, 2013 3:32 PM
Last Updated: Thursday, August 29, 2013 10:18 AM
Posted by: Mikesdotnetting
Total Views to date: 13953

5 Comments

Thursday, August 8, 2013 4:52 PM - yousaid

GREAT article !! This is what I was looking for.
Thanks again Mike !

Thursday, August 8, 2013 6:19 PM - yousaid

I think there's a typo in this line
car commandText = @"SELECT ProductId, ProductName, QuantityPerUnit, UnitPrice FROM Products .
IT should be:
var commandText = @"SELECT ProductId, ProductName, QuantityPerUnit, UnitPrice FROM Products

NOT "car"

Thursday, August 29, 2013 10:19 AM - Mike

@Yousaid,

Well spotted!

Friday, December 6, 2013 3:07 PM - Quintus

Mike, several of the images in this post are not being found and returning 404

Saturday, December 7, 2013 8:33 PM - Mike

@Quintus,

Thanks - I was in the middle of moving my hosting. Some of the images were a bit obstinate.
Add your comment

If you have any comments to make about this article, please use this form to do so. Make sure that your comment relates specifically to the article above. More general comments can be posted through the form on the Contact page.

Please note, all comments are moderated, and some may not be published. The kind of things that will ensure your comment is deleted without ever seeing the light of day are as follows:

  • Not relevant to the article
  • Gratuitous links to your own site or product
  • Anything abusive or libellous
  • Spam
  • Anything in a language I don't understand including gibberish.

I do not pass email addresses on to spammers, so a valid one will assist me in responding to you personally if required.