Razor Web Pages E-Commerce - Adding A Shopping Cart To The Bakery Template Site

The ASP.NET Web Pages Bakery template site is designed as a starting point for a site that displays products. It also features an order form for customers to specify a quantity for a specific product and provide their shipping address. The site doesn't provide for multiple product selection. If you want a shopping cart for the Bakery site, you need to create it yourself. This article looks at the design considerations behind an effective shopping cart, and shows how to build one.

The first design consideration is storage. Any shopping cart needs to be available across multiple pages. There are a number of options for persisting state between ASP.NET Web Pages. Form fields and URLs are not good places for maintaining the contents of a shopping cart as they do not persist beyond the series of pages that the form fields or URLs appear within. Application variables are totally unsuitable as everyone will share the same data - and it will all be lost if the application restarts. Likewise, in-process session variables are not robust enough. They will be lost if the application pool recycles, which it can do regularly in a shared hosting environment. You really don't want to present your user with an empty cart after they had filled it up with goodies, just because an app pool recycle cleared it out. In addition, none of the previous methods persist data beyond one visit. You could use cookies to store details of a user's selections except that they are limited in the amount of information they can hold. The most robust approach is to use a database.

The next consideration is when to identify the user. The user will have to provide an identity before they can actually make a purchase, and for this they will need to register an account with your site. However, they don't necessarily need to do this to select some products to purchase. Most e-commerce sites recognise that they should allow anonymous users to fill a shopping cart without forcing them to provide personal details first. This article will show how to manage that scenario. In a future article, I will demonstrate how to take the process one step further and require registration at the point of checkout.

Database Design

The existing Bakery database is very simple. It contains one table: Products. Shopping carts are also very simple. A basic one only needs two tables: Cart and CartItems:

[Cart]
CartId		int	IDENTITY Primary Key Not Null
UserId		int
DateCreated	datetime NOT NULL
CheckedOut	bit NOT NULL Default 0
[CartItems]
CartItemId	int	IDENTITY Primary Key Not Null
CartId		int
ProductId	int
Quantity	int
Price		money

The Cart table has a UserId value, although anonymous users will be allowed to create a cart. The UserId column will be used in a future article to associate a cart with a registered user. CartItems has a one-to-many relationship with the Cart table via the CartId column. It also has a one-to-many relationship with the Products table via the ProductId column. You might wonder why Price has been included in the CartItems table when it already appears in the Products table. You may want to change the price of products from time to time in the Products table, but you do not want those changes to affect the price already paid for historic orders. Therefore you keep a record of the price paid separate to the "marketing" price in the products table. If you offer promotional discounts from time to time, you may want to add another column to the CartItems table to record the one that applied to a particular product.

If you want to add these tables quickly to your Bakery template site, go to the Databases workspace in WebMatrix, ensure you are on the Home tab and then click New Query. Copy and paste the first of the following Create Table statements and click Execute. Then repeat for the second one.

CREATE TABLE Cart (
    CartId int IDENTITY Primary Key NOT NULL,
    UserId int,
    DateCreated datetime NOT NULL,
    CheckedOut bit NOT NULL Default 0
)
CREATE TABLE CartItems (
    CartItemId int IDENTITY Primary Key NOT NULL,
    CartId int NOT NULL,
    ProductId int NOT NULL,
    Quantity int NOT NULL,
    Price money NOT NULL
)

Shopping Cart

Code

Having created a site from the Bakery template and made the changes to the database outlined above, it's time to begin coding. I made only one change to the Default.cshtml file that comes with the site and that was to remove the hard-coded dollar signs from the prices that appear with each product and to change the format string to use "c" instead of "f" so that the regional currency will be picked up from the server:

@string.Format("{0:c}", p.Price)

There were about 3 places where price or featuredprice appeared that required this change. I like seeing British pound signs in my samples.

The Order.cshtml page requried quite a few changes, not least to the HTML which is shown below:

<ol id="orderProcess">
    <li><span class="step-number">1</span><a href="~/">Choose Item</a></li>
    <li class="current"><span class="step-number">2</span>Place Order</li>
    <li><span class="step-number">3</span><a href="~/ReviewCart">Review Cart</a></li>
</ol>

<h1>Place Your Order: @product.Name</h1>
<form action="" method="post">
    <fieldset class="no-legend">
        <legend>Place Your Order</legend>
        <img class="product-image order-image" src="~/Images/Products/Thumbnails/@product.ImageName" alt="Image of @product.Name"/>
        <ul class="orderPageList" data-role="listview">
            <li>
                <div>
                    <p class="description">@product.Description</p>
                </div>                
            </li>
            <li class="quantity">
                <div class="fieldcontainer" data-role="fieldcontain">
                    <label for="orderQty">Quantity</label>
                    <input type="text" id="orderQty" name="orderQty" value="@(quantity == 0 ? 1 : quantity)"/>
                    x
                    <span id="orderPrice">@string.Format("{0:f}", product.Price)</span>
                    =
                    <span id="orderTotal">@string.Format("{0:f}", quantity == 0 ? product.Price : quantity * product.Price)</span>
                </div>
            </li>
        </ul>
        <p class="actions">
            <input type="hidden" name="productId" value="@product.Id" />
            <input type="hidden" name="price" value="@product.Price" />
            @if(!IsPost){
                <input type="submit" value="Place Order" data-role="none" data-inline="true"/>
            }
        </p>
        <div id="basket">
            @if(totalItems > 0){
                <text>Your cart contains <strong>@totalItems</strong> items</text>
            }
        </div>
    </fieldset>
</form>

The navigation a the top of the file has been amended so that the third link now points to a page called ViewCart.cshtml rather than OrderSuccess.cshtml. The email address and shipping address fields have been removed and the value for the quantity is now generated dynamically so that it is persisted when the form is submitted. The orderTotal is also generated initially by server side code so that it is persisted across form submissions, although the JavaScript that updates it when a quantity is selected is left in the file (just not included here). The Place Order button has been wrapped in a conditional block so that it only appears if the from has not been submitted. Finally, a div is added for displaying the current number of items in a basket if the totalItems variable is greater than 0.

The workflow is as follows: when a user selects a product for the first time and submits an order, a cart is created for that user and entered into the Cart table. Then the details of the order are committed as a new entry in the CartItem table. The cart is then associated to that user by persistent cookie to which is written the ID of the cart. The cookie is set to expire 6 months into the future. So long as the user doesn't delete their cookies, the site will be able to identify that the user has been there before and has a cart. Here's the code block at the top of the file in its entirety:

@{
    Page.Title = "Place Your Order";

    var db = Database.Open("bakery");
    var productId = UrlData[0].AsInt();
    var price = Request["price"].AsDecimal();
    var quantity = Request["orderQty"].AsInt();
    var commandText = string.Empty;
    var cartId = 0;
    var totalItems = 0;    
    
    commandText = "SELECT * FROM PRODUCTS WHERE ID = @0";
    var product = db.QuerySingle(commandText, productId);
    if (product == null) {
        Response.Redirect("~/");
    }

    if(Request.Cookies["cart"] != null){
        cartId = Request.Cookies["cart"].Value.AsInt();
        commandText = "SELECT SUM(Quantity) AS TotalItems FROM CartItems WHERE CartId = @0";
        object result = db.QueryValue(commandText, cartId);
        totalItems = result == DBNull.Value ? 0 : Convert.ToInt32(result);
    }

    if(IsPost && quantity > 0){
        if(Request.Cookies["cart"] == null){
            commandText = "INSERT INTO Cart (DateCreated) VALUES (GetDate())";
            db.Execute(commandText);
            cartId = (int)db.GetLastInsertId();
        }
        commandText = "SELECT Quantity FROM CartItems WHERE CartId = @0 AND ProductId = @1";
        var reduction = db.QueryValue(commandText, cartId, productId);
        if(reduction != null){
            totalItems -= reduction;
        }
        commandText = "DELETE FROM CartItems WHERE CartId = @0 AND ProductId = @1";
        db.Execute(commandText, cartId, productId);
        commandText = "INSERT INTO CartItems (CartId, ProductId, Quantity, Price) VALUES (@0, @1, @2, @3)";
        db.Execute(commandText, cartId, productId, quantity, price);
        totalItems += quantity;
        Response.Cookies["cart"].Value = cartId.ToString();
        Response.Cookies["cart"].Expires = DateTime.Now.AddMonths(6);
    }
}

The code (ignoring the title setting line) is divided into 4 blocks. The first block consists of various variables being declared and initialised. Most of these are the same as in the original Bakery template site. The additions are integers for the cart ID and total items in the cart, and a string for holding SQL commands. The second block attempts to retrieve product details for the product with the ID that was passed in the URL. If no matching product is found, the user is redirected to the home page. This code is more or less unchanged from the original.

The third section of code checks to see if a cookie exists with the name "cart". If it does, the user has already created a cart as some stage. The cart's ID is obtained from the cookie and this is used to query the database for the total number of items in the cart.

The last block of code executes if the user has submitted the form and has specified a quantity greater than 0. If no cart exists (determined through checking for the existence of the cookie) one is created. This is achieved through setting the DateCreated field value to the SQL GetDate() function value which is the equivalent of .NET's DateTime.Now. Then the Database.GetLastInsertId method is used to obtain the value if the newly created record. That represents the cart's ID.

It is assumed that any order submitted for a particular product is either the first order for that product or an amendment to an existing order for that product - meaning that existing entries in the cart for the selected product should be replaced with the new submission. A query is executed against the database to determine the quantity of any existing orders for the product. The Database.QueryValue method is used, which expects a scalar value in return (since the query only wants the value from one field). If no records match the criteria, QueryValue returns null. Otherwise, the value of an existing row is retuned, which needs to be deducted from the current totalItems value. Then any matching rows are deleted from the CartItems table. Following that, a new row is added to the table containing details of the current order submission, and the totalItems variable is adjusted accordingly to give a revised count of items. Finally, a cookie is written to the browser with the cart's ID and set to expire in 6 months time.

Reviewing the Cart

The final step of the order process presented in this sample is the ability to review the contents of the cart and to remove items.

Shopping Cart

This is provided by ReviewCart.cshtml. Here is the HTML and script part of the page:

<ol id="orderProcess">
    <li><span class="step-number">1</span><a href="~/">Choose Item</a></li>
    <li><span class="step-number">2</span><a href="~/Order/1">Place Order</a></li>
    <li class="current"><span class="step-number">3</span>Review Cart</li>
</ol>
<h1>Review Your Cart</h1>
<p>&nbsp;</p>
<div id="grid">
@grid.GetHtml(
    columns: grid.Columns(
    grid.Column("Name", "Product", format: @<a href="~/Order/@item.ID">@item.Name</a>),
        grid.Column("Quantity"),
        grid.Column("Price", "Unit Price", format: @<text>@item.Price.ToString("c")</text>),
        grid.Column("Price", "Total Price", format: @<text>@item.Total.ToString("c")</text>),
        grid.Column("", format: @<form method="post">
                                <input type="hidden" value="@item.CartItemId" name="cartItem" />
                                <input type="submit" value="Remove" />
                                </form>)
    )
)
</div>
<p>&nbsp;</p>

@section scripts{
<script>
    $(function () {
        var html = '<tfoot><tr><td colspan="3"><strong>Total</strong></td><td>';
        html += '<strong>@cartTotal.ToString("c")</strong>'
        html += '</td><td colspan="2">&nbsp;</td></tr></tfoot>';
        $('table').append(html);
    });
</script>
}

The cart itself is presented in a WebGrid. The grid has had a footer added to it via jQuery, and this is used to display the total value of the cart. This is about the easiest way to add footers to WebGrids. Each row in the grid has a Delete button in the last column. If you look at the format parameter for hte last column, you can see that individual forms are provided for each product. The product's ID is stored in a hidden field so that it is not visible to the user but will be transferred to the server if the associated button is clicked. Let's look at what else happens on the server:

@{
    Page.Title = "Review Your Cart";
    
    if(Request.Cookies["cart"] == null){
        Response.Redirect("~/");
    }

    var db = Database.Open("Bakery");
    var cartId = Request.Cookies["cart"].Value.AsInt();
    var commandText = string.Empty;
    
    if(IsPost){
        commandText = "DELETE FROM CartItems WHERE CartItemId = @0";
        db.Execute(commandText, Request["cartItem"]);
    }

    commandText = @"SELECT p.ID, p.Name, c.Quantity, c.Price,  c.Quantity * c.Price AS Total, c.CartItemId
                        FROM CartItems c INNER JOIN Products p 
                        ON c.ProductId = p.Id WHERE c.CartId = @0";
    var cartItems = db.Query(commandText, cartId);
    var cartTotal = cartItems.Sum(t => (decimal)t.Total);
    var grid = new WebGrid(cartItems, canPage: false, canSort: false);
}

Well, first, a check is made to see if a cart (cookie) exists and if not, the user is redirected to the home page. If the visitor passes that particular test the rest of the code will execute. After some variables are declared, a check is made to see if any of the forms have been submitted to delete a row from the basket. If one has, the corresponding entry in the CartItems table is removed. Ths is done before a query is executed to obtain the cart's contents for display. Notice that the cartTotal variable (which is later used in the jQuery function to add athe footer) is generated using the Enumerable.Sum() extension method. Another way to obtain this value would have been to execute a separate SQL query against the database, but it is a lot more efficient to use LINQ queries on data that has been retrieved than to query the database again.

Summary

E-Commerce Shopping Carts seem to hold a lot of mystique for relatively inexperienced developers. However, they are very simple things. The one illustrated in this example is just a couple of small database tables and some beginner-level CRUD operations using the Database helper. It's not finished. There needs to be some way to associate the cart to a user's account, and I will be looking at that in a forthcoming article. Also, you might want to add some maintenance routines such as deleting anonymous carts that are more than 6 months old.

A sample site containing the code featured in this article is available to download from GitHub.