Solution

JavaScript EditorFreeware javascript editor     Javascript code


Main Page

Previous Page
Next Page

Solution

We'll go very quickly through the implementation of the solution, as the structure of many classes and pages is similar to those developed for previous modules. In particular, creation of the database tables, the stored procedure, the configuration code, and the DAL classes is completely skipped in this chapter, due to space constraints. Of course, you'll find the complete details in the code download. Instead, I'll focus this space on the implementation of code containing new ASP.NET 2.0 features not already discussed, and code containing interesting logic, such as the shopping cart profile class and the companion classes, as well as the checkout process and the integration with PayPal.

Implementing the Business Logic Layer

First we'll examine the BLL classes related to the shopping cart, starting with the ShoppingCartItem class, which is an entity class that wraps data for an item in the cart, with its title, SKU, ID, unit price, and quantity. This class is decorated with the [Serializable] attribute, which is necessary to allow the ASP.NET profile system to persist the ShoppingCartItem objects. Here's the code:

[Serializable]
public class ShoppingCartItem
{
   private int _id = 0;
   public int ID
   {
      get { return _id; }
      private set { _id = value; }
   }

   private string _title = "";
   public string Title
   {
      get { return _title; }
      private set { _title = value; }
   }

   private string _sku = "";
   public string SKU
   {

      get { return _sku; }
      private set { _sku = value; }
   }

   private decimal _unitPrice;
   public decimal UnitPrice
   {
      get { return _unitPrice; }
      private set { _unitPrice = value; }
   }

   private int _quantity = 1;
   public int Quantity
   {
      get { return _quantity; }
      set { _quantity = value; }
   }

   public ShoppingCartItem(int id, string title, string sku, decimal unitPrice)
   {
      this.ID = id;
      this.Title = title;
      this.SKU = sku;
      this.UnitPrice = unitPrice;
   }
}

The ShoppingCart class exposes a number of methods for inserting, removing, and retrieving multiple ShoppingCartItem objects to and from an internal Dictionary object instantiated for that type. When an item is inserted, the class checks whether the Dictionary already contains an item with the same ID: If not, it adds it; otherwise, it increments the Quantity property of the existing item. The RemoveItem method works similarly, but it decrements the Quantity if the item is found; if the Quantity reaches 0, it completely removes the item from the shopping cart. RemoveProduct suggests the same action, but it's actually different, because it removes a product from the cart, regardless of its quantity. UpdateItemQuantity updates an item's quantity, and is used when the customer edits the quantities in the shopping cart page. Finally, the Clear method empties the shopping cart by clearing the internal Dictionary. Here's the complete code:

[Serializable]
public class ShoppingCart
{
   private Dictionary<int, ShoppingCartItem> _items =
      new Dictionary<int, ShoppingCartItem>();

   public ICollection Items
   {
      get { return _items.Values; }
   }

   /// <summary>
   /// Gets the sum total of the items' prices
   /// </summary>
   public decimal Total

   {
      get
      {
         decimal sum = 0.0m;
         foreach (ShoppingCartItem item in _items.Values)
            sum += item.UnitPrice * item.Quantity;
         return sum;
      }
   }

   /// <summary>
   /// Adds a new item to the shopping cart
   /// </summary>
   public void InsertItem(int id, string title, string sku, decimal unitPrice)
   {
      if (_items.ContainsKey(id))
         _items[id].Quantity += 1;
      else
         _items.Add(id, new ShoppingCartItem(id, title, sku, unitPrice));
   }

   /// <summary>
   /// Removes an item from the shopping cart
   /// </summary>
   public void DeleteItem(int id)
   {
      if (_items.ContainsKey(id))
      {
         ShoppingCartItem item = _items[id];
         item.Quantity -= 1;
         if (item.Quantity == 0)
            _items.Remove(id);
      }
   }

   /// <summary>
   /// Removes all items of a specified product from the shopping cart
   /// </summary>
   public void DeleteProduct(int id)
   {
      if (_items.ContainsKey(id))
      {
         _items.Remove(id);
      }
   }

   /// <summary>
   /// Updates the quantity for an item
   /// </summary>
   public void UpdateItemQuantity(int id, int quantity)
   {
      if (_items.ContainsKey(id))
      {
         ShoppingCartItem item = _items[id];

         item.Quantity = quantity;
         if (item.Quantity <= 0)
            _items.Remove(id);
      }
   }

   /// <summary>
   /// Clears the cart
   /// </summary>
   public void Clear()
   {
      _items.Clear();
   }
}

If you now go to the root's web.config file and change the <profile> section according to what is shown below, you'll have a fully working persistent shopping cart, also available to anonymous users:

<profile defaultProvider="TBH_ProfileProvider">
   <providers>...</providers>
   <properties>
      <add name="FirstName" type="String" />
      <add name="LastName" type="String" />
      ...
      <add name="ShoppingCart"
         type="MB.TheBeerHouse.BLL.Store.ShoppingCart"
         serializeAs="Binary" allowAnonymous="true" />
   </properties>
</profile>

Important 

Note the class defined above is not serializable to XML, because a default constructor for the ShoppingCartItem is not present, and the ShoppingCart's Item property does not have a setter accessory. These requirements do not exist for binary serialization, though, and because of this I chose to use this serialization method and create more encapsulated classes.

With a few dozen lines of code we've accomplished something that previous versions of ASP.NET would have required hours of work to accomplish by creating database tables, stored procedures, and DAL classes. As explained earlier, we're also creating a helper CurrentUserShoppingCart that will be used to bind the current user's ShoppingCart profile property to a GridView, and other controls, by means of the ObjectDataSource component. Its implementation is very short, as its methods just reference the ShoppingCart profile property from the current context, and forward the call to its respective methods:

public class CurrentUserShoppingCart
{
   public static ICollection GetItems()
   {
      return (HttpContext.Current.Profile as
         ProfileCommon).ShoppingCart. Items;

   }

   public static void DeleteItem(int id)
   {
      (HttpContext.Current.Profile as ProfileCommon).ShoppingCart.DeleteItem(id);
   }

   public static void DeleteProduct(int id)
   {
      (HttpContext.Current.Profile as
         ProfileCommon).ShoppingCart.DeleteProduct(id);
   }
}

Remember to update the Profile_MigrateAnonymous event handler in the global.asax file to migrate the ShoppingCart property from the anonymous user's profile to the profile of the member who just logged in. However, you must do it only if the anonymous customer's shopping cart is not empty, because otherwise you would always erase the registered customer's shopping cart:

void Profile_MigrateAnonymous(object sender, ProfileMigrateEventArgs e)
{
   // get a reference to the previously anonymous user's profile
   ProfileCommon anonProfile = this.Profile.GetProfile(e.AnonymousID);
   // if set, copy its Theme and ShoppingCart to the current user's profile
   if (anonProfile.ShoppingCart.Items.Count > 0)
      this.Profile.ShoppingCart = anonProfile.ShoppingCart;
   ...
}

Next we'll look at the Order class, for which GetOrderByID looks like all the Get{xxx}ByID methods of the other business classes in other modules:

public static Order GetOrderByID(int orderID)
{
   Order order = null;
   string key = "Store_Order_" + orderID.ToString();

   if (BaseStore.Settings.EnableCaching && BizObject.Cache[key] != null)
   {
      order = (Order)BizObject.Cache[key];
   }
   else
   {
      order = GetOrderFromOrderDetails(SiteProvider.Store.GetOrderByID(orderID));
      BaseStore.CacheData(key, order);
   }
   return order;
}

However, the GetOrderFromOrderDetails method of this class doesn't just wrap the data of a single DAL's entity class into a business class instance, it also retrieves all details for child OrderItems, wraps them into read-only OrderItem business objects, and set the order's Items property with them:

private static Order GetOrderFromOrderDetails(OrderDetails record)
{
   if (record == null)
      return null;
   else
   {

      // create a list of OrderItems for the order
      List<OrderItem> orderItems = new List<OrderItem>();
      List<OrderItemDetails> recordset = SiteProvider.Store.GetOrderItems(
         record.ID);

      foreach (OrderItemDetails item in recordset)
      {
         orderItems.Add(new OrderItem(item.ID, item.AddedDate, item.AddedBy,
            item.OrderID, item.ProductID, item.Title, item.SKU,
            item.UnitPrice, item.Quantity));
      }

      // create new Order
      return new Order(record.ID, record.AddedDate, record.AddedBy,
         record.StatusID, record.StatusTitle, record.ShippingMethod,
         record.SubTotal, record.Shipping, record.ShippingFirstName,
         record.ShippingLastName, record.ShippingStreet, record.ShippingPostalCode,
         record.ShippingCity, record.ShippingState, record.ShippingCountry,
         record.CustomerEmail, record.CustomerPhone, record.CustomerFax,
         record.ShippedDate, record.TransactionID, record.TrackingID, orderItems);
   }
}

Another interesting method is InsertOrder, which accepts an instance of ShoppingCart with all the order items, and other parameters for the customer's contact information and the shipping address. Because it must insert multiple records (a record into tbh_Orders, and one or more records into tbh_OrderDetails), it must ensure that all these operations are executed in an atomic way so that all operations are committed or rolled back in case we get an exception. To ensure this, we're using the System.Transactions.TransactionScope class, new in .NET Framework 2.0. Refer to Chapter 3 to read about this class and its alternatives. The following code shows how it's used in a real situation:

public static int InsertOrder(ShoppingCart shoppingCart,
   string shippingMethod, decimal shipping, string shippingFirstName,
   string shippingLastName, string shippingStreet, string shippingPostalCode,
   string shippingCity, string shippingState, string shippingCountry,
   string customerEmail, string customerPhone, string customerFax,
   string transactionID)
{
   using (TransactionScope scope = new TransactionScope())
   {
      string userName = BizObject.CurrentUserName;

      // insert the master order
      OrderDetails order = new OrderDetails(0, DateTime.Now,
         userName, 1, "", shippingMethod, shoppingCart.Total, shipping,
         shippingFirstName, shippingLastName, shippingStreet, shippingPostalCode,
         shippingCity, shippingState, shippingCountry, customerEmail,
         customerPhone, customerFax, DateTime.MinValue, transactionID, "");

      int orderID = SiteProvider.Store.InsertOrder(order);

      // insert the child order items
      foreach (ShoppingCartItem item in shoppingCart.Items)
      {
         OrderItemDetails orderItem = new OrderItemDetails(0, DateTime.Now,
            userName, orderID, item.ID, item.Title, item.SKU,
            item.UnitPrice, item.Quantity);
         SiteProvider.Store.InsertOrderItem(orderItem);
      }

      BizObject.PurgeCacheItems("store_order");
      scope.Complete();

      return orderID;
   }
}

A transaction is also used in the UpdateOrder method. The detail records that are updated in this method relate to a single record of the tbh_Orders table (no order item is touched, however, as they are all read-only after an order is saved), and if the new order status is "confirmed" it also decreases the UnitsInStock value of all items purchased in the order, according to the ordered quantity:

public static bool UpdateOrder(int id, int statusID, DateTime shippedDate,
   string transactionID, string trackingID)
{
   using (TransactionScope scope = new TransactionScope())
   {
      transactionID = BizObject.ConvertNullToEmptyString(transactionID);
      trackingID = BizObject.ConvertNullToEmptyString(trackingID);

      // retrieve the order's current status ID
      Order order = Order.GetOrderByID(id);

      // update the order
      OrderDetails record = new OrderDetails(id, DateTime.Now, "", statusID, "",
         "", 0.0m, 0.0m, "", "", "", "", "", "", "", "", "", "", shippedDate,
         transactionID, trackingID);
      bool ret = SiteProvider.Store.UpdateOrder(record);

      // if the new status ID is "confirmed", than decrease the UnitsInStock
      // for the purchased products
      if (statusID == (int)StatusCode.Confirmed &&
         order.StatusID == (int)StatusCode.WaitingForPayment)
      {
         foreach (OrderItem item in order.Items)
            Product.DecrementProductUnitsInStock(item.ProductID, item.Quantity);
      }

      BizObject.PurgeCacheItems("store_order");
      scope.Complete();
      return ret;
   }
}

The StatusCode enumeration used in the preceding code includes the three built-in statuses required by the module: waiting for payment, confirmed, and verified, and is defined as follows:

public enum StatusCode : int
{
   WaitingForPayment = 1,
   Confirmed = 2,
   Verified = 3
}

Note, however, that because the StatusID property is an integer, an explicit cast to int is required. The StatusID type is int and not StatusCode because users can define their own additional status codes, and thus working with numeric IDs is more appropriate in most situations.

The last significant method in the Order class is GetPayPalPaymentUrl, which returns the URL to redirect the customer to PayPal to pay for the order. It dynamically builds the URL shown in the "Design" section with the amount, shipping, and order ID values taken from the current order, plus the recipient business e-mail and currency code taken from the configuration settings, and the return URLs that point to the OrderCompleted.aspx, OrderCancelled.aspx, and Notify.aspx pages described earlier:

public string GetPayPalPaymentUrl()
{
   string serverUrl = (Globals.Settings.Store.SandboxMode ?
      "https://www.sandbox.paypal.com/us/cgi-bin/webscr" :
      "https://www.paypal.com/us/cgi-bin/webscr");
   string amount = this.SubTotal.ToString("N2").Replace(',', '.');
   string shipping = this.Shipping.ToString("N2").Replace(',', '.');

   string baseUrl = HttpContext.Current.Request.Url.AbsoluteUri.Replace(
      HttpContext.Current.Request.Url.PathAndQuery, "") +
      HttpContext.Current.Request.ApplicationPath;
   if (!baseUrl.EndsWith("/"))
      baseUrl += "/";

   string notifyUrl = HttpUtility.UrlEncode(baseUrl + "PayPal/Notify.aspx");
   string returnUrl = HttpUtility.UrlEncode(
      baseUrl + "PayPal/OrderCompleted.aspx?ID="+ this.ID.ToString());
   string cancelUrl = HttpUtility.UrlEncode(
      baseUrl + "PayPal/OrderCancelled.aspx");
   string business = HttpUtility.UrlEncode(Globals.Settings.Store.BusinessEmail);
   string itemName = HttpUtility.UrlEncode("Order #" + this.ID.ToString());

   StringBuilder url = new StringBuilder();
   url.AppendFormat(
      "{0}?cmd=_xclick&upload=1&rm=2&no_shipping=1&no_note=1&currency_code={1}&
      business={2}&item_number={3}&custom={3}&item_name={4}&amount={5}&
      shipping={6}&notify_url={7}&return={8}&cancel_return={9}",
      serverUrl, Globals.Settings.Store.CurrencyCode, business, this.ID, itemName,
      amount, shipping, notifyUrl, returnUrl, cancelUrl);

   return url.ToString();
}

Note that the method uses a different base URL according to whether the store runs in real or test mode, as indicated by the Sandbox configuration setting. Also note that PayPal expects all amounts to use the period (.) as a separator for the amount's decimal parts, and only wants two decimal digits. You use variable.ToString("N2") to format the double or decimal variable as a string with two decimal digits. However, if the current locale settings are set to Italian or some other country's settings for which a comma is used, you'll get something like "33,50" instead of "33.50." For this reason you also do a Replace for "," with "." just in case.

Implementing the User Interface

Many administrative and end-user pages of this module are similar in structure of those in previous chapters, especially to those of the articles module described and implemented in Chapter 5. For example, in Figure 9-8 you can see how similar the page to manage departments is to the page to manage product categories.

Image from book
Figure 9-8

The page to manage shipping options, represented in Figure 9-9, is also similar: the GridView and DetailsView controls used to list and insert/modify records just define different fields, but the structure of the page is nearly identical.

Image from book
Figure 9-9

Note that in the page for managing order status, status records with IDs from 1 to 3 cannot be deleted, because they identify special, hard-coded values. For example, you've just seen in the implementation of the Order class that the UpdateOrder method checks whether the current order status is 2, in which case it decrements the UnitsInStock field of the ordered products. Because of this, you should handle the RowCreated event of the GridView displaying the records, and ensure that the Delete LinkButton is hidden for the first three records. Following is the code to place into this event handler, while Figure 9-10 shows the final result on the page:

protected void gvwOrderStatuses_RowCreated(object sender, GridViewRowEventArgs e)
{
   if (e.Row.RowType == DataControlRowType.DataRow)
   {
      ImageButton btn = e.Row.Cells[2].Controls[0] as ImageButton;

      int orderStatusID = Convert.ToInt32(
         gvwOrderStatuses.DataKeys[e.Row.RowIndex][0]);

      if (orderStatusID > 3)
         btn.OnClientClick = "if (confirm('Are you sure you want to delete this
order status?') == false) return false;";
      else
         btn.Visible = false;
   }
}
Image from book
Figure 9-10

The AddEditProduct.aspx page uses a single DetailsView and a companion ObjectDataSource, and enables to you to edit an existing product or insert a new one according to the presence of an ID parameter on the querystring. The same thing was done (and shown in detail) in Chapter 5, so please refer to that chapter to see the implementation. Figure 9-11 shows how the resulting page looks.

Image from book
Figure 9-11

The ManageProducts.aspx page provides links to the other catalog management pages, and shows the list of the products of a specific department if a DepID parameter is found on the querystring; otherwise, it shows products from all departments. The actual listing is produced by the ProductListing.ascx user control, which is then plugged into ManageProducts.aspx: The code that creates the listing is placed there so that it can be reused in the end-user BrowseProducts.aspx page. The control contains a GridView control that defines the following columns:

  • An ImageField that shows the product's small image, whose URL is stored in the SmallImageUrl field

  • A HyperLinkField that creates a link that points to the product-specific page at ~/Show Product.aspx, with the product's ID passed on the querystring. The product's Title is used as the link's text.

  • A TemplateField that shows the RatingDisplay user control developed in Chapter 5, bound to the product's AverageRating calculated property, to display the product's customer rating. However, this control is only displayed if there is at least one vote, and this is done by binding the control's Visible property to an expression.

  • A TemplateField that shows the AvailabilityDisplay user control (implemented shortly) to represent the product's availability with icons of different colors. The control's Value property is bound to the UnitsInStock field of the product, which is the value used to select an icon. The same field is also used as SortExpression for the column, so that by clicking on this column's header, a customer, administrator, or storekeeper can see the products with the greatest or least availability first.

  • A TemplateField to show the product's price. A BoundField bound to the UnitPrice field would not be enough, because if the product's DiscountPercentage value is greater than 0, then the UnitPrice amount must be displayed as crossed out, and the DiscountPercentage must be shown along with the calculated FinalUnitPrice.

  • A TemplateField with a graphical hyperlink that links to the AddEditProduct.aspx page, passing the product's ID on the querystring, to edit the product. This column will be hidden from the GridView's RowCreated event if the current user does not belong to the Administrators or StoreKeepers roles.

  • A CommandField that raises the Delete command and thus deletes the product. This column will be hidden from the GridView's RowCreated event if the current user does not belong to the Administrators or StoreKeepers roles.

Here's the code that defines the GridView just described:

<asp:GridView ID="gvwProducts" runat="server" AllowPaging="True"
   AutoGenerateColumns="False" DataKeyNames="ID" DataSourceID="objProducts"
   PageSize="10" AllowSorting="True" OnRowCreated="gvwProducts_RowCreated">
   <Columns>
      <asp:ImageField DataImageUrlField="SmallImageUrl" ItemStyle-Width="110px" />
      <asp:HyperLinkField HeaderText="Product" SortExpression="Title"
         HeaderStyle-HorizontalAlign="Left" DataTextField="Title"
         DataNavigateUrlFormatString="~/ShowProduct.aspx?ID={0}"
         DataNavigateUrlFields="ID" />

      <asp:TemplateField HeaderText="Rating">
         <ItemTemplate>
            <div style="text-align: center">
            <mb:RatingDisplay runat="server" ID="ratDisplay"
               Value='<%# Eval("AverageRating") %>'
               Visible='<%# (int)Eval("Votes") > 0 %>' />
            </div>
         </ItemTemplate>
      </asp:TemplateField>
      <asp:TemplateField HeaderText="Available" SortExpression="UnitsInStock"
         ItemStyle-HorizontalAlign="Center">
         <ItemTemplate>
            <div style="text-align: center">
               <mb:AvailabilityDisplay runat="server" ID="availDisplay"
                  Value='<%# Eval("UnitsInStock") %>' />
            </div>
         </ItemTemplate>
      </asp:TemplateField>
      <asp:TemplateField HeaderText="Price" SortExpression="UnitPrice"
         HeaderStyle-HorizontalAlign="Right">
         <ItemTemplate>
            <div style="text-align: right">
               <asp:Panel runat="server"
                  Visible='<%# (int)Eval("DiscountPercentage") > 0 %>'>
                  <s><%# (this.Page as BasePage).FormatPrice(
                     Eval("UnitPrice")) %></s><br />
                  <b><%# Eval("DiscountPercentage") %>% Off</b><br />
               </asp:Panel>
               <%# (this.Page as BasePage).FormatPrice(Eval("FinalUnitPrice")) %>
            </div>
         </ItemTemplate>
      </asp:TemplateField>
      <asp:TemplateField ItemStyle-HorizontalAlign="Center" ItemStyle-Width="20px">
         <ItemTemplate>
            <asp:HyperLink runat="server" ID="lnkEdit" ToolTip="Edit product"
               NavigateUrl='<%# "~/Admin/AddEditProduct.aspx?ID="+ Eval("ID") %>'
               ImageUrl="~/Images/Edit.gif" />
         </ItemTemplate>
      </asp:TemplateField>
      <asp:CommandField ButtonType="Image" DeleteImageUrl="~/Images/Delete.gif"
         DeleteText="Delete product"ShowDeleteButton="True"
         ItemStyle-HorizontalAlign="Center" ItemStyle-Width="20px" />
   </Columns>
   <EmptyDataTemplate><b>No products to show</b></EmptyDataTemplate>
</asp:GridView>

There are other controls on the page, such as DropDownList controls to choose the parent department and the number of products to list on the page, but we've already created something almost identical for the ArticleListing.ascx control, so you can refer back to it for the code and the details. If you look closely at the column that displays the product's price, you'll see that it calls a method named FormatPrice to show the amount. This method is added to the BasePage class and it formats the input value as a number with two decimal digits, followed by the currency code defined in the configuration settings:

public string FormatPrice(object price)
{
   return Convert.ToDecimal(price).ToString("N2") + ""+
      Globals.Settings.Store.CurrencyCode;
}

Amounts are not displayed on the page with the default currency format (which would use the "C" format string) because you may be running the store in another country, such as Italy, which would display the euro symbol in the string; but you want to display USD regardless of the current locale settings. The other control used in the preceding code and not yet implemented is the AvailabilityDisplay.ascx user control. It just declares an Image control on the .ascx markup file, and then it defines a Value property in the code-behind class, which sets the image according to the input value:

public partial class AvailabilityDisplay : System.Web.UI.UserControl
{
   private int _value = 0;
   public int Value
   {
      get { return _value; }
      set
      {
         _value = value;
         if (_value <= 0)
         {
            imgAvailability.ImageUrl = "~/images/lightred.gif";
            imgAvailability.AlternateText = "Currently not available";
         }
         else if (_value <= Globals.Settings.Store.LowAvailability)
         {
            imgAvailability.ImageUrl = "~/images/lightyellow.gif";
            imgAvailability.AlternateText = "Few units available";
         }
         else
         {
            imgAvailability.ImageUrl = "~/images/lightgreen.gif";
            imgAvailability.AlternateText = "Available";
         }
      }
   }

   // the Page_Init event handler, and LoadControlState/SaveControlState
   // methods for the control's state management go here...
}

Figure 9-12 shows the page at runtime.

Image from book
Figure 9-12

The ShowProduct.aspx Page

The BrowseProducts.aspx page only contains one line to reference the ProductListing user control, so we can jump straight to the product-specific ShowProduct.aspx page. This shows all the possible details about the product whose ID is passed on the querystring: the title, the average rating, the availability icon, the HTML long description, the small image (or a default "no image available" image if the SmallImageUrl field is empty), a link to the full-size image (displayed only if the FullImageUrl field is not empty), and the product's price. As for the product listing, the UnitPrice amount is shown if the DiscountPercentage is 0; otherwise, that amount is rendered as crossed out, and DiscountPercentage along with the FinalUnitPrice are displayed. Finally, there's a button on the page that will add the product to the customer's shopping cart and will redirect the customer to the ShoppingCart.aspx page. Following is the content of the .aspx markup page:

<table style="width: 100%;" cellpadding="0" cellspacing="0">
   <tr><td>
      <asp:Label runat="server" ID="lblTitle" CssClass="articletitle" />
   </td>
   <td style="text-align: right;">
      <asp:Panelrunat="server" ID="panEditProduct">
      <asp:HyperLink runat="server"ID="lnkEditProduct"
         ImageUrl="~/Images/Edit.gif"
         ToolTip="Edit product" NavigateUrl="~/Admin/AddEditProduct.aspx?ID={0}" />
      <asp:ImageButton runat="server" ID="btnDelete"
         CausesValidation="false" AlternateText="Delete product"
            ImageUrl="~/Images/Delete.gif"
         OnClientClick="if (confirm('Are you sure you want to delete this
product?') == false) return false;" OnClick="btnDelete_Click" />
      </asp:Panel>
   </td></tr>
</table>
<p></p>
<b>Price: </b><asp:Literal runat="server" ID="lblDiscountedPrice">
   <s>{0}</s> {1}% Off = </asp:Literal>
<asp:Literal runat="server" ID="lblPrice" />
<p></p>
<b>Availability: </b>
   <mb:AvailabilityDisplay runat="server" ID="availDisplay" /><br />
<b>Rating: </b><asp:Literal runat="server" ID="lblRating"
   Text="{0} user(s) have rated this product "/>
<mb:RatingDisplay runat="server"ID="ratDisplay" />
<p></p>
<div style="float: left; padding: 4px; text-align: center;">
   <asp:Image runat="Server" ID="imgProduct" ImageUrl="~/Images/noimage.gif"
      GenerateEmptyAlternateText="true" /><br />
   <asp:HyperLink runat="server" ID="lnkFullImage" Font-Size="XX-Small"
      Target="_blank">Full-size<br />image</asp:HyperLink>
</div>
<asp:Literal runat="server" ID="lblDescription" />
<p></p>
<asp:Button ID="btnAddToCart" runat="server"
   OnClick="btnAddToCart_Click" Text="Add to Shopping Cart" />

You can see that there's no binding expression in the preceding code, because everything is done from the Page_Load event handler, after loading a Product object according to the ID value read from the querystring. Once you have such an object, you can set all the Text, Value, and Visible properties of the various controls defined above, as follows:

protected void Page_Load(object sender, EventArgs e)
{
   if (string.IsNullOrEmpty(this.Request.QueryString["ID"]))
      throw new ApplicationException("Missing parameter on the querystring.");
   else
      _productID = int.Parse(this.Request.QueryString["ID"]);

   if (!this.IsPostBack)
   {

      // try to load the product with the specified ID, and raise
      // an exception if it doesn't exist
      Product product = Product.GetProductByID(_productID);
      if (product == null)
         throw new ApplicationException("No product found for the specified ID.");

      // display all article's data on the page
      this.Title = string.Format(this.Title, product.Title);
      lblTitle.Text = product.Title;
      lblRating.Text = string.Format(lblRating.Text, product.Votes);
      ratDisplay.Value = product.AverageRating;
      ratDisplay.Visible = (product.Votes > 0);
      availDisplay.Value = product.UnitsInStock;
      lblDescription.Text = product.Description;
      panEditProduct.Visible = this.UserCanEdit;
      lnkEditProduct.NavigateUrl = string.Format(
         lnkEditProduct.NavigateUrl, _productID);
      lblPrice.Text = this.FormatPrice(product.FinalUnitPrice);
      lblDiscountedPrice.Text = string.Format(lblDiscountedPrice.Text,
         this.FormatPrice(product.UnitPrice), product.DiscountPercentage);
      lblDiscountedPrice.Visible = (product.DiscountPercentage > 0);
      if (product.SmallImageUrl.Length > 0)
         imgProduct.ImageUrl = product.SmallImageUrl;
      if (product.FullImageUrl.Length > 0)
      {
         lnkFullImage.NavigateUrl = product.FullImageUrl;
         lnkFullImage.Visible = true;
      }
      else
         lnkFullImage.Visible = false;

      // hide the rating box controls if the current user has
      // already voted for this product...
   }
}

A screenshot of the result is shown in Figure 9-13.

Image from book
Figure 9-13

When the customer clicks the Add to Shopping Cart button, we call the InsertItem method of the ShoppingCart object returned by the profile property, and pass in the product's data read from the Product object. Finally, we redirect the customer to the ShoppingCart.aspx page, where he can change the quantity of the products to order and proceed to the checkout process:

protected void btnAddToCart_Click(object sender, EventArgs e)
{
   Product product = Product.GetProductByID(_productID);
   this.Profile.ShoppingCart.InsertItem(
      product.ID, product.Title, product.SKU, product.FinalUnitPrice);
   this.Response.Redirect("ShoppingCart.aspx", false);
}

The ShoppingCart.aspx Page

As described earlier in the "Design" section for the user interface, this page is actually more complex than a page that just manages the shopping cart, as it includes a complete wizard for the checkout process, which includes steps to provide the contact information and the shipping address, and to review the order a last time before being redirected to PayPal for the payment. ASP.NET 2.0 introduces the new Wizard control, which allows us to define different views within it; and it automatically creates and manages the buttons/links at the bottom of the wizard to move backward and forward through the wizard's steps. The control's structure is outlined in the following code:

<asp:Wizard ID="wizSubmitOrder" runat="server" ActiveStepIndex="0"
   CancelButtonText="Continue Shopping" CancelButtonType="Link"
   CancelDestinationPageUrl="~/BrowseProducts.aspx" DisplayCancelButton="True"
   DisplaySideBar="False" FinishPreviousButtonType="Link"
   StartNextButtonText="Proceed with order" StartNextButtonType="Link" Width="100%"
   StepNextButtonText="Proceed with order" StepNextButtonType="Link"
   StepPreviousButtonText="Modify data in previous step"
   StepPreviousButtonType="Link" FinishCompleteButtonText="Submit Order"
   FinishCompleteButtonType="Link"
   FinishPreviousButtonText="Modify data in previous step"
   OnFinishButtonClick="wizSubmitOrder_FinishButtonClick"
   OnActiveStepChanged="wizSubmitOrder_ActiveStepChanged">

   <StepNextButtonStyle Font-Bold="True" />
   <StartNextButtonStyle Font-Bold="True" />
   <FinishCompleteButtonStyle Font-Bold="True" />
   <FinishPreviousButtonStyle Font-Bold="True" />

   <WizardSteps>
      <asp:WizardStep runat="server" Title="Shopping Cart">

      </asp:WizardStep>
      <asp:WizardStep runat="server" Title="Shipping Address">

      </asp:WizardStep>
      <asp:WizardStep runat="server" Title="Order Confirmation">

      </asp:WizardStep>
   </WizardSteps>
</asp:Wizard>

There's a <WizardSteps> section used to define one <asp:WizardStep> control for each step you want the wizard to have. The WizardStep is a template-based control used to declare the content of that step. The parent Wizard control has a number of properties that enable you to completely customize the visual appearance of the commands at the bottom, in addition to their text. The properties used in the preceding code are self-explanatory. The Wizard control also exposes a number of methods that a developer can handle to run code when the current step changes, or when the user clicks the button to complete the wizard. There will also be a Cancel command in each step that we'll use as the command to continue shopping, so it just redirects the user to the BrowseProducts.aspx page.

Let's start with the first step. It defines a GridView control with a companion ObjectDataSource that binds the grid to the list of ShoppingCartItem objects returned by CurrentUserShoppingCart. GetItems. The grid has a column for the item's title, a column that shows the item's price, a templated column that creates an editable textbox with the quantity for that product, and finally a column with a command link to completely remove that item from the shopping cart (which would be the same as manually setting the product's quantity to 0, and clicking the button to update the totals). Below the grid we define a Label that displays the shopping cart's subtotal amount, a DropDownList that lists the available shipping options and their price, and a final Label that displays the order's total amount. The Labels are updated when a button is clicked. Following is the code that goes into the first WizardStep control:

<asp:WizardStep runat="server" Title="Shopping Cart">
   <div class="sectiontitle">Shopping Cart</div>
   <p></p>Review and update the quantity of the products added to the cart before
   proceeding to checkout, or continue shopping.<p></p>
   <asp:GridView ID="gvwOrderItems" runat="server" AutoGenerateColumns="False"
      DataSourceID="objShoppingCart" Width="100%" DataKeyNames="ID"
      OnRowDeleted="gvwOrderItems_RowDeleted"
      OnRowCreated="gvwOrderItems_RowCreated">
      <Columns>
         <asp:HyperLinkField DataTextField="Title"
            HeaderStyle-HorizontalAlign="Left"
            DataNavigateUrlFormatString="~/ShowProduct.aspx?ID={0}"
            DataNavigateUrlFields="ID" HeaderText="Product" >
         </asp:HyperLinkField>
         <asp:TemplateField HeaderText="Price" HeaderStyle-HorizontalAlign="Right">
            <ItemTemplate>
               <div style="text-align: right">
                  <%# FormatPrice(Eval("UnitPrice")) %>
               </div>
            </ItemTemplate>
         </asp:TemplateField>
         <asp:TemplateField HeaderText="Quantity" ItemStyle-Width="60px">
            <ItemTemplate>
               <div style="text-align: right;">
                  <asp:TextBox runat="server" ID="txtQuantity"
                     Text='<%# Bind("Quantity") %>' MaxLength="6" Width="30px" />
                  <asp:RequiredFieldValidator ID="valRequireQuantity"
                     runat="server" ControlToValidate="txtQuantity"
                     SetFocusOnError="true" ValidationGroup="ShippingAddress"
                     Text="The Quantity field is required."
                     ToolTip="The Quantity field is required." Display="Dynamic" />
                  <asp:CompareValidator ID="valQuantityType" runat="server"
                     Operator="DataTypeCheck" Type="Integer"
                     ControlToValidate="txtQuantity" Display="dynamic"
                     Text="The Quantity must be an integer."
                     ToolTip="The Quantity must be an integer." />
               </div>
             </ItemTemplate>
          </asp:TemplateField>
          <asp:CommandField ButtonType="Image" DeleteImageUrl="~/Images/Delete.gif"
             DeleteText="Delete product" ShowDeleteButton="True" />
       </Columns>
       <EmptyDataTemplate><b>The shopping cart is empty</b></EmptyDataTemplate>
    </asp:GridView>
    <asp:ObjectDataSource ID="objShoppingCart" runat="server"
       SelectMethod="GetItems" DeleteMethod="DeleteProduct"
       TypeName="MB.TheBeerHouse.BLL.Store.CurrentUserShoppingCart" />
    <asp:Panel runat="server" ID="panTotals">
    <div style="text-align: right; font-weight: bold; padding-top: 4px;">
       Subtotal: <asp:Literal runat="server" ID="lblSubtotal" />
       <p>
       Shipping Method:
       <asp:DropDownList ID="ddlShippingMethods" runat="server"
          DataSourceID="objShippingMethods"
          DataTextField="TitleAndPrice" DataValueField="Price">

      </asp:DropDownList>
      <asp:ObjectDataSource ID="objShippingMethods" runat="server"
         SelectMethod="GetShippingMethods"
         TypeName="MB.TheBeerHouse.BLL.Store.ShippingMethod" />
      </p>
      <p>
      <u>Total:</u> <asp:Literal runat="server" ID="lblTotal" />
      </p>
      <asp:Button ID="btnUpdateTotals" runat="server"
         OnClick="btnUpdateTotals_Click" Text="Update totals" />
   </div>
   </asp:Panel>
</asp:WizardStep>

In the page's code-behind file is an UpdateTotals method that is called when a row is deleted from the GridView (a product was completely removed from the shopping cart) or when the customer clicks the Update button, typically after changing a product's quantity or selecting a shipping method. The UpdateTotals method loops through the rows of the GridView control, and for each row it finds the textbox control with the product's quantity, reads its value, and uses it to update the quantity of the product stored in the shopping cart, by means of the ShoppingCart.UpdateItemQuantity method. Then it can display the order's subtotal and total amounts according to the updated quantities and the currently selected shipping method. Finally, it checks whether the shopping cart actually contains something, because if that's not the case it doesn't make sense for the customer to proceed to the next step of the checkout wizard. Curiously, the Wizard control has no properties to explicitly disable the Next and Previous commands, but you can do that by setting the command's text to an empty string, so that they won't be visible. The property to set in this case is StartNextButtonText, because we are in the Start step (i.e., the first one) and we want to disable the Next command. Here's the implementation for this first part of the wizard:

protected void gvwOrderItems_RowDeleted(object sender, GridViewDeletedEventArgs e)
{
   UpdateTotals();
}

protected void btnUpdateTotals_Click(object sender, EventArgs e)
{
   UpdateTotals();
}

protected void UpdateTotals()
{
   // update the quantities
   foreach (GridViewRow row in gvwOrderItems.Rows)
   {
      int id = Convert.ToInt32(gvwOrderItems.DataKeys[row.RowIndex][0]);
      int quantity = Convert.ToInt32(
         (row.FindControl("txtQuantity") as TextBox).Text);
      this.Profile.ShoppingCart.UpdateItemQuantity(id, quantity);
   }

   // display the subtotal and the total amounts
   lblSubtotal.Text = this.FormatPrice(this.Profile.ShoppingCart.Total);
   lblTotal.Text = this.FormatPrice(this.Profile.ShoppingCart.Total +

      Convert.ToDecimal(ddlShippingMethods.SelectedValue));

   // if the shopping cart is empty, hide the link to proceed
   if (this.Profile.ShoppingCart.Items.Count == 0)
   {
      wizSubmitOrder.StartNextButtonText = "";
      panTotals.Visible = false;
   }

   gvwOrderItems.DataBind();
}

Figure 9-14 shows a sample screenshot of this first step at runtime.

Image from book
Figure 9-14

The second step is simpler than the first one; it's just a form that asks for contact information and the shipping address. This information is pre-filled with the information stored in the customer's profile, if provided, but customers can change everything in this form if they're buying a gift for someone and want the product(s) to be shipped directly to that person. Also, at this point a user account is required to proceed, so if the current user is anonymous, then she will be asked to log in or create a new user account, instead of displaying the input form. To do this, a MultiView control with two views is used, and the index of the desired view will be dynamically set when the page loads if the index of the wizard's current step is 1 (second step), according to whether the user is authenticated. A more traditional approach would have been to create two Panels and show/hide them according to that condition, but the MultiView control was intended to be a more elegant alternative to that solution. In practice, it's just a wizard control under the hood without the automatically created buttons to move forward and backward. Here's the markup code:

<asp:WizardStep runat="server" Title="Shipping Address">
   <div class="sectiontitle">Shipping Address</div>
   <p></p>
   <asp:MultiView ID="mvwShipping" runat="server">
      <asp:View ID="vwLoginRequired" runat="server">
         An account is required to proceed with the order submission. If you
         already have an account please login now, otherwise
         <a href="Register.aspx">create a new account</a> for free.
      </asp:View>
      <asp:View ID="vwShipping" runat="server">
         Fill the form below with the shipping address for your order...
         <p></p>
         <table cellpadding="2" width="410">
            <tr>
               <td width="110" class="fieldname">
                  <asp:Label runat="server" ID="lblFirstName"
                     AssociatedControlID="txtFirstName" Text="First name:" />
               </td>
               <td width="300">
                  <asp:TextBox ID="txtFirstName" runat="server" Width="100%" />
                  <asp:RequiredFieldValidator ID="valRequireFirstName"
                     runat="server" ControlToValidate="txtFirstName"
                     SetFocusOnError="true" ValidationGroup="ShippingAddress"
                     Text="The First Name field is required." Display="Dynamic"
                     ToolTip="The First Name field is required." />
               </td>
            </tr>
            <tr>
               <td class="fieldname">
                  <asp:Label runat="server" ID="lblLastName"
                     AssociatedControlID="txtLastName" Text="Last name:" />
               </td>
               <td>
                  <asp:TextBox ID="txtLastName" runat="server" Width="100%" />
                  <asp:RequiredFieldValidator ID="valRequireLastName"
                     runat="server" ControlToValidate="txtLastName"
                     SetFocusOnError="true" ValidationGroup="ShippingAddress"
                     Text="The Last Name field is required." Display="Dynamic"
                     ToolTip="The Last Name field is required." />
               </td>
            </tr>
            <!-- more rows for more shipping information here... -->
         </table>
      </asp:View>
   </asp:MultiView>
</asp:WizardStep>

Following is the code-behind that pre-fills the various textboxes if the current user is logged in. Also note that UpdateTotals is called in this event so that the totals are updated correctly even when the customer changed some quantities and proceeded to the next step without clicking the Update Total button:

protected void Page_Load(object sender, EventArgs e)
{
   if (!Page.IsPostBack)
   {...}
   else
   {
      bool isAuthenticated = this.User.Identity.IsAuthenticated;
      mvwShipping.ActiveViewIndex = (isAuthenticated ? 1 : 0);
      wizSubmitOrder.StepNextButtonText = (isAuthenticated ?
         wizSubmitOrder.StartNextButtonText : "");
   }
}

protected void wizSubmitOrder_ActiveStepChanged(object sender, EventArgs e)
{
   if (wizSubmitOrder.ActiveStepIndex == 1)
   {
       UpdateTotals();

       if (this.User.Identity.IsAuthenticated)
       {
          if (ddlCountries.Items.Count == 1)
          {
             ddlCountries.DataSource = Helpers.GetCountries();
             ddlCountries.DataBind();
          }

          if (txtFirstName.Text.Trim().Length == 0)
             txtFirstName.Text = this.Profile.FirstName;
          if (txtLastName.Text.Trim().Length == 0)
             txtLastName.Text = this.Profile.LastName;
          if (txtEmail.Text.Trim().Length == 0)
             txtEmail.Text = Membership.GetUser().Email;
          if (txtStreet.Text.Trim().Length == 0)
             txtStreet.Text = this.Profile.Address.Street;
          if (txtPostalCode.Text.Trim().Length == 0)
             txtPostalCode.Text = this.Profile.Address.PostalCode;
          if (txtCity.Text.Trim().Length == 0)
             txtCity.Text = this.Profile.Address.City;
          if (txtState.Text.Trim().Length == 0)
             txtState.Text = this.Profile.Address.State;
          if (ddlCountries.SelectedIndex == 0)
             ddlCountries.SelectedValue = this.Profile.Address.Country;
          if (txtPhone.Text.Trim().Length == 0)
             txtPhone.Text = this.Profile.Contacts.Phone;
          if (txtFax.Text.Trim().Length == 0)
             txtFax.Text = this.Profile.Contacts.Fax;
      }
   }
}

Figure 9-15 shows what this step looks like on the page at runtime.

Image from book
Figure 9-15

The last step allows the customer to review all data inserted so far: the name, price, and quantity of the products she's about to order, the subtotal amount, the shipping method and its price, the total amount, as well as her personal contact information and the shipping address. The WizardStep template defines a number of Labels for most of this information, and a Repeater control bound to the same ObjectDataSource used for the first step's GridView to show the items in the shopping cart:

<asp:WizardStep runat="server" Title="Order Confirmation">
   <div class="sectiontitle">Order Summary</div>
   <p></p>
   Please carefully review the order information below...
   <p></p>
   <img src="Images/paypal.gif" style="float: right" />
   <b>Order Details</b>
   <p></p>
   <asp:Repeater runat="server" ID="repOrderItems" DataSourceID="objShoppingCart">
      <ItemTemplate>
         <img src="Images/ArrowR3.gif" border="0" />
         <%# Eval("Title") %> - <%# FormatPrice(Eval("UnitPrice")) %>
         <small>(Quantity = <%# Eval("Quantity") %>)</small>
         <br />
      </ItemTemplate>
   </asp:Repeater>

   <br />
   Subtotal = <asp:Literal runat="server" ID="lblReviewSubtotal" />
   <p></p>
   Shipping Method = <asp:Literal runat="server" ID="lblReviewShippingMethod" />
   <p></p>
   <u>Total</u> = <asp:Literal runat="server" ID="lblReviewTotal" />
   <p></p>
   <b>Shipping Details</b>
   <p></p>
   <asp:Literal runat="server" ID="lblReviewFirstName" />
   <asp:Literal runat="server" ID="lblReviewLastName" /><br />
   <asp:Literal runat="server" ID="lblReviewStreet" /><br />
   <asp:Literal runat="server" ID="lblReviewCity" />,
   <asp:Literal runat="server" ID="lblReviewState" />
   <asp:Literal runat="server" ID="lblReviewPostalCode" /><br />
   <asp:Literal runat="server" ID="lblReviewCountry" />
</asp:WizardStep>

When this step loads, we confirm that the wizard's ActiveStepIndex is 2, and then show all the information in the controls:

protected void wizSubmitOrder_ActiveStepChanged(object sender, EventArgs e)
{
   if (wizSubmitOrder.ActiveStepIndex == 1)
   { ... }
   else if (wizSubmitOrder.ActiveStepIndex == 2)
   {
      lblReviewFirstName.Text = txtFirstName.Text;
      lblReviewLastName.Text = txtLastName.Text;
      lblReviewStreet.Text = txtStreet.Text;
      lblReviewCity.Text = txtCity.Text;
      lblReviewState.Text = txtState.Text;
      lblReviewPostalCode.Text = txtPostalCode.Text;
      lblReviewCountry.Text = ddlCountries.SelectedValue;

      lblReviewSubtotal.Text = this.FormatPrice(this.Profile.ShoppingCart.Total);
      lblReviewShippingMethod.Text = ddlShippingMethods.SelectedItem.Text;
      lblReviewTotal.Text = this.FormatPrice(this.Profile.ShoppingCart.Total +
         Convert.ToDecimal(ddlShippingMethods.SelectedValue));
   }
}

The step is displayed as shown in Figure 9-16.

Image from book
Figure 9-16

If the Finish button is clicked, the wizard's FinishButtonClick event handler will save the shopping cart's content as a new order in the database, clear the shopping cart, and use the GetPayPalPaymentUrl of the new Order instance to get the PayPal URL; and then we'll redirect the customer to pay for the ordered products. However, before doing all this we must determine whether the customer is still authenticated. In fact, consider the situation when the customer gets to this last step, and then goes away from the computer, maybe to find her credit card. When she comes back, her authentication cookie may have expired in the meantime, in which case we'd get an empty shopping cart for an anonymous user when accessing Profile.ShoppingCart. Therefore, if the current user is not authenticated at this point, then we'll redirect her to the page that requests the login; otherwise, we'll go ahead and send her to the PayPal site:

protected void wizSubmitOrder_FinishButtonClick(object sender,
   WizardNavigationEventArgs e)
{

   if (this.User.Identity.IsAuthenticated)
   {
      string shippingMethod = ddlShippingMethods.SelectedItem.Text;
      shippingMethod = shippingMethod.Substring(0,
         shippingMethod.LastIndexOf('('));

      // saves the order into the DB, and clear the shopping cart in the profile
      int orderID = Order.InsertOrder(this.Profile.ShoppingCart, shippingMethod,
         Convert.ToDecimal(ddlShippingMethods.SelectedValue),
         txtFirstName.Text, txtLastName.Text, txtStreet.Text, txtPostalCode.Text,
         txtCity.Text, txtState.Text, ddlCountries.SelectedValue, txtEmail.Text,

         txtPhone.Text, txtFax.Text, "");

      this.Profile.ShoppingCart.Clear();

      // redirect to PayPal for the credit-card payment
      Order order = Order.GetOrderByID(orderID);
      this.Response.Redirect(order.GetPayPalPaymentUrl(), false);
   }
   else
      this.RequestLogin();
}

Figure 9-17 is a screenshot of the PayPal payment page, run from inside the Sandbox test environment. Note that the subtotal, shipping, and total amounts are exactly the same as those shown in the previous figures.

Image from book
Figure 9-17

Handing the Customer's Return from PayPal

When the customer cancels the order while she's on PayPal's page, she is redirected to the Order Cancelled.aspx page, which has just a couple of lines of static feedback instructions explaining how she can pay at a later time. If she completes the payment, then she'll be directed to the OrderCompleted .aspx page instead: It expects the ID of the order paid by the customer on the querystring, so that it can load an Order object for it, and then it updates its StatusID property from "waiting for payment" to "confirmed," but not yet "verified":

protected void Page_Load(object sender, EventArgs e)
{
   Order order = Order.GetOrderByID(Convert.ToInt32(
      this.Request.QueryString["ID"]));
   if (order.StatusID == (int)StatusCode.WaitingForPayment)
   {
      order.StatusID = (int)StatusCode.Confirmed;
      order.Update();
   }
}

Figure 9-18 shows screenshots for both pages.

Image from book
Figure 9-18

The Notify.aspx page is the one that receives the IPN notification. As explained earlier, the first thing you do in this page is verify that the notification is real and was not faked by a dishonest user. To do this, you send the notification data back to PayPal using HttpWebRequest, and see if PayPal responds with a VERIFIED string:

private bool IsVerifiedNotification()
{
   string response = "";
   string post = Request.Form.ToString() + "&cmd=_notify-validate";
   string serverUrl = (Globals.Settings.Store.SandboxMode ?
      "https://www.sandbox.paypal.com/us/cgi-bin/webscr" :
      "https://www.paypal.com/us/cgi-bin/webscr");

   HttpWebRequest req = (HttpWebRequest)WebRequest.Create(serverUrl);
   req.Method = "POST";
   req.ContentType = "application/x-www-form-urlencoded";
   req.ContentLength = post.Length;

   StreamWriter writer = new StreamWriter(req.GetRequestStream(),
      System.Text.Encoding.ASCII);
   writer.Write(post);
   writer.Close();

   StreamReader reader = new StreamReader(req.GetResponse().GetResponseStream());
   response = reader.ReadToEnd();
   reader.Close();

   return (response == "VERIFIED");
}

This method is called from inside the Page_Load handler, and if the check succeeds, we extract some data from the request's parameters, such as custom (the order ID), payment_status (a string describing the current status for the order transaction) and mc_gross (the order's total amount). Then we get a reference to the Order object according to the order ID obtained from the notification, and we check whether the total amount stored in the database matches the amount indicated by the PayPal notification. If so, we update the order status to "verified." Here's the code:

protected void Page_Load(object sender, EventArgs e)
{
   if (IsVerifiedNotification())
   {
      int orderID = Convert.ToInt32(this.Request.Params["custom"]);
      string status = this.Request.Params["payment_status"];
      decimal amount = Convert.ToDecimal(this.Request.Params["mc_gross"],
         CultureInfo.CreateSpecificCulture("en-US"));

      // get the Order object corresponding to the input orderID,
      // and check that its total matches the input total
      Order order = Order.GetOrderByID(orderID);
      decimal origAmount = (order.SubTotal + order.Shipping);
      if (amount >= origAmount)
      {
         order.StatusID = (int)StatusCode.Verified;
         order.Update();
      }
   }
}

In the preceding code, when parsing the mc_gross string to a decimal value, a CultureInfo object for en-US (English for U.S.) is passed to the Convert.ToDecimal call. This is because PayPal always uses a period (.) as separator for the decimal part of the number, but if the current thread's locale is set to some other culture that uses a comma for the separator, the string would have been parsed incorrectly without this code.

Important 

Note that there can be many more parameters that PayPal passes to your page in the IPN notifications than those used here. I strongly suggest you to refer to PayPal's documentation for the full coverage of these parameters, and for the guide on how to activate and set up the IPN notifications from your PayPal's account settings, which is not covered here.

The ShoppingCart.ascx User Control

So far I haven't shown any links to the ShoppingCart.aspx page, but we want the cart to be visible on any page. The shopping cart's current content should always be visible as well, so that the customer does not need to go to ShoppingCart.aspx just to see whether she's already put a product into the cart. We also want customers to see the subtotal so they won't get any surprises when they proceed to checkout. All this information can easily be shown on a user control that will be plugged into the site's master page, so it will always be present. The ShoppingCart.ascx control defines a Repeater control that's similar to the one used earlier in the last step of the ShoppingCart.aspx page, which shows the current list of shopping cart items with their name, unit price, and quantity. Below that is a label for displaying the shopping cart's total amount, and a hyperlink to the full ShoppingCart.aspx page, where the customer can change quantities and proceed with the checkout. If also defines a link to the OrderHistory.aspx page, which we'll create next:

<asp:Repeater runat="server" ID="repOrderItems">
   <ItemTemplate>
      <small>
      <asp:Image runat="Server" ID="imgProduct"
         ImageUrl="~/Images/ArrowR3.gif" GenerateEmptyAlternateText="true" />
      <%# Eval("Title") %> -
      <%# (this.Page as BasePage).FormatPrice(Eval("UnitPrice")) %>
      <small>(<%# Eval("Quantity") %>)</small><br />
   </ItemTemplate>
</asp:Repeater>
<br />
<b><asp:Literal runat="server" ID="lblSubtotalHeader" Text="Subtotal = "/>
<asp:Literal runat="server" ID="lblSubtotal" /></b>
<asp:Literal runat="server" ID="lblCartIsEmpty" Text="Your cart is currently
empty." />
<p></p>
<asp:Panel runat="server" ID="panLinkShoppingCart">
   <asp:HyperLink runat="server" ID="lnkShoppingCart"
      NavigateUrl="~/ShoppingCart.aspx">Detailed Shopping Cart</asp:HyperLink>
</asp:Panel>
<asp:HyperLink runat="server" ID="lnkOrderHistory"
   NavigateUrl="~/OrderHistory.aspx" >Order History</asp:HyperLink>

In the control's code-behind class, we just handle the Load event to bind the Repeater with the data returned by the Items property of the Profile.ShoppingCart object, show the total amount in the label, and hide the panel with the link to ShoppingCart.aspx if the cart is empty:

protected void Page_Load(object sender, EventArgs e)
{
   if (!this.IsPostBack)
   {
      if (this.Profile.ShoppingCart.Items.Count > 0)
      {
         repOrderItems.DataSource = this.Profile.ShoppingCart.Items;
         repOrderItems.DataBind();

         lblSubtotal.Text = (this.Page as BasePage).FormatPrice(
            this.Profile.ShoppingCart.Total);
         lblSubtotal.Visible = true;
         lblSubtotalHeader.Visible = true;
         panLinkShoppingCart.Visible = true;
         lblCartIsEmpty.Visible = false;
      }
      else
      {
         lblSubtotal.Visible = false;
         lblSubtotalHeader.Visible = false;
         panLinkShoppingCart.Visible = false;
         lblCartIsEmpty.Visible = true;
      }
   }
}

Figure 9-19 shows how the control looks — both empty and not empty.

Image from book
Figure 9-19

The OrderHistory.aspx Page

This page contains a DataList that lists all past orders for the current authenticated user. The DataList's template section shows the order's title, the total amount, and the title of the current status, plus the detailed list of all order items, rendered by a Repeater similar to those used earlier. If the order's StatusID is 1 (waiting for payment), it also renders a link to the PayPal payment page, retrieved by means of the order's GetPayPalPaymentUrl method, already used in the last step of the ShoppingCart.aspx page's checkout wizard. At the end of the template it also displays the subtotal amount, and the shipping method's title and cost:

<asp:DataList runat="server" ID="dlstOrders">
   <ItemTemplate>
      <div class="sectionsubtitle">
         Order #<%# Eval("ID") %> - <%# Eval("AddedDate", "{0:g}") %></div>
      <br />
      <img src="Images/ArrowR4.gif" border="0" /> <u>Total</u> =
      <%# FormatPrice((decimal)Eval("SubTotal") + (decimal)Eval("Shipping")) %>
      <img src="Images/ArrowR4.gif" border="0" /> <u>Status</u> =
         <%# Eval("StatusTitle") %>
      <asp:HyperLink runat="server" ID="lnkPay" Font-Bold="true" Text="Pay Now"
         NavigateUrl='<%# (Container.DataItem as Order).GetPayPalPaymentUrl() %>'
         Visible = '<%# ((int)Eval("StatusID")) == 1 %>' /><p></p>
      <small>
      <b>Details</b><br />
      <asp:Repeater runat="server" ID="repOrderItems"
         DataSource='<%# Eval("Items") %>'>
         <ItemTemplate>
            <img src="Images/ArrowR3.gif" border="0" />
            <%# Eval("Title") %> - <%# FormatPrice(Eval("UnitPrice")) %>
               <small>(Quantity = <%# Eval("Quantity") %>)</small><br />
         </ItemTemplate>
      </asp:Repeater>
      <br />
      Subtotal = <%# FormatPrice(Eval("SubTotal")) %><br />
      Shipping Method = <%# Eval("ShippingMethod") %>
         (<%# FormatPrice(Eval("Shipping")) %>)
      </small>
   </ItemTemplate>

   <SeparatorTemplate>
      <hr style="width: 99%; noshade: noshade;" />
   </SeparatorTemplate>
</asp:DataList>

The page's code-behind contains only a couple of lines that bind the DataList with the list of orders returned by the Order.GetOrders overloaded method, which accepts the name of the current user:

dlstOrders.DataSource = Order.GetOrders(this.User.Identity.Name);
dlstOrders.DataBind();

Figure 9-20 is a screenshot of the page.

Image from book
Figure 9-20

The ManageOrders.aspx and EditOrder.aspx Pages

This administrative page is used by storekeepers to retrieve the list of orders in a certain status created in a specified date interval, or to retrieve the list of all orders made by a given customer. If the storekeeper already knows the ID of a specific order and wants to update it, there's a form that lets her enter the ID and click the button to jump to the edit page. The following markup code defines the input forms to filter the data as described, as well as the GridView that actually displays the found orders, with their title, list of order items (through the usual Repeater already used, which shows the SKU field in addition to the others, as this is useful information for storekeepers), the subtotal amount, and the shipping amount. On the right side of each order row there's also a button to delete the order, but that will only be shown to Administrators, and not to StoreKeepers, as it's a sensitive operation that should only be performed rarely, and never by accident:

Status: <asp:DropDownList ID="ddlOrderStatuses" runat="server"
   DataSourceID="objAllStatuses" DataTextField="Title" DataValueField="ID" />
<asp:ObjectDataSource ID="objAllStatuses" runat="server"
   SelectMethod="GetOrderStatuses"
   TypeName="MB.TheBeerHouse.BLL.Store.OrderStatus" />
from: <asp:TextBox ID="txtFromDate" runat="server" Width="80px" />
to: <asp:TextBox ID="txtToDate" runat="server" Width="80px" />
<asp:Button ID="btnListByStatus" runat="server" Text="Load"
   OnClick="btnListByStatus_Click" ValidationGroup="ListByStatus" />
<!-- validator controls here ... -->

<div class="sectionsubtitle">Orders by customer</div>
Name: <asp:TextBox ID="txtCustomerName" runat="server" />
<asp:Button ID="btnListByCustomer" runat="server" Text="Load"
   OnClick="btnListByCustomer_Click" ValidationGroup="ListByCustomer" />

<div class="sectionsubtitle">Order Lookup</div>
ID: <asp:TextBox ID="txtOrderID" runat="server" />
<asp:Button ID="btnOrderLookup" runat="server" Text="Find"
   OnClick="btnOrderLookup_Click" ValidationGroup="OrderLookup" />
<asp:Label runat="server" ID="lblOrderNotFound" SkinID="FeedbackKO"
   Text="Order not found!" Visible="false" />

<asp:GridView ID="gvwOrders" runat="server" AutoGenerateColumns="False"
   Width="100%" DataKeyNames="ID" OnRowDeleting="gvwOrders_RowDeleting"
   OnRowCreated="gvwOrders_RowCreated">
   <Columns>
      <asp:BoundField HeaderText="Date" DataField="AddedDate"
         HeaderStyle-HorizontalAlign="Left" DataFormatString="{0:d}<br />{0:t}"
         HtmlEncode="False" />
      <asp:BoundField HeaderText="Customer" DataField="AddedBy"
         HeaderStyle-HorizontalAlign="Left" />
      <asp:TemplateField HeaderText="Items" HeaderStyle-HorizontalAlign="Left">
         <ItemTemplate>
            <small>
            <asp:Repeater runat="server" ID="repOrderItems"
               DataSource='<%# Eval("Items") %>'>
               <ItemTemplate>
                  <img src="../Images/ArrowR3.gif" border="0" />
                   [<%# Eval("SKU") %>]
                   <asp:HyperLink runat="server" ID="lnkProduct"
                      Text='<%# Eval("Title") %>'

                      NavigateUrl='<%# "~/ShowProduct.aspx?ID="+
                         Eval("ProductID") %>' />
                  - (<%# Eval("Quantity") %>)
                  <br />
               </ItemTemplate>
            </asp:Repeater>
            </small>
         </ItemTemplate>
      </asp:TemplateField>
      <asp:BoundField HeaderText="Subtotal" DataField="SubTotal"
         HeaderStyle-HorizontalAlign="Right" ItemStyle-HorizontalAlign="Right"
         DataFormatString="{0:N2}" HtmlEncode="False" />
      <asp:BoundField HeaderText="Shipping" DataField="Shipping"
         HeaderStyle-HorizontalAlign="Right" ItemStyle-HorizontalAlign="Right"
         DataFormatString="{0:N2}" HtmlEncode="False" />
      <asp:HyperLinkField Text="<img border='0' src='../Images/ArrowR.gif' />"
         DataNavigateUrlFormatString="EditOrder.aspx?ID={0}"
         DataNavigateUrlFields="ID"
         ItemStyle-HorizontalAlign="Center" ItemStyle-Width="20px" />
      <asp:CommandField ButtonType="Image" DeleteImageUrl="~/Images/Delete.gif"
         DeleteText="Delete order" ShowDeleteButton="True"
         ItemStyle-HorizontalAlign="Center" ItemStyle-Width="20px" />
   </Columns>
   <EmptyDataTemplate><b>No orders to show</b></EmptyDataTemplate>
</asp:GridView>

When the page loads, the textbox for the end date of the date interval is pre-filled with the current date, while the textbox for the start date is pre-filled with the current date minus the number of days specified in the DefaultOrderListInterval configuration setting:

protected void Page_Load(object sender, EventArgs e)
{
   if (!this.IsPostBack)
   {
      txtToDate.Text = DateTime.Now.ToShortDateString();
      txtFromDate.Text = DateTime.Now.Subtract(
         new TimeSpan(Globals.Settings.Store.DefaultOrderListInterval,
         0, 0, 0)).ToShortDateString();
   }

   lblOrderNotFound.Visible = false;
   // if the user is not an admin, hide the grid's column with the delete button
   gvwOrders.Columns[6].Visible = (this.User.IsInRole("Administrators"));
}

When the user types an ID into the txtOrderID textbox and clicks the Lookup Order button, we need to determine whether an order with that ID exists: If so, then we redirect her to the EditOrder.aspx page (that you'll see shortly), passing the order's ID on the querystring; otherwise, we show a Label with an error message:

protected void btnOrderLookup_Click(object sender, EventArgs e)
{
   // if the order with the specified ID is not found, show the error label,
   // otherwise redirect to EditOrder.aspx with the ID on the querystring
   Order order = Order.GetOrderByID(Convert.ToInt32(txtOrderID.Text));
   if (order == null)
      lblOrderNotFound.Visible = true;
   else
      this.Response.Redirect("EditOrder.aspx?ID="+ txtOrderID.Text);
}

When the other two buttons are clicked, we'll set a GridView's attribute indicating the filter mode, and call the DoBinding method to load the orders, by means of one of the two overloads for the Order.GetOrders method:

protected void btnListByCustomer_Click(object sender, EventArgs e)
{
   gvwOrders.Attributes.Add("ListByCustomers", true.ToString());
   DoBinding();
}

protected void btnListByStatus_Click(object sender, EventArgs e)
{
   gvwOrders.Attributes.Add("ListByCustomers", false.ToString());
   DoBinding();
}

protected void DoBinding()
{
   bool listByCustomers = false;
   if (!string.IsNullOrEmpty(gvwOrders.Attributes["ListByCustomers"]))
      listByCustomers = bool.Parse(gvwOrders.Attributes["ListByCustomers"]);

   List<Order> orders = null;
   if (listByCustomers)
   {
      orders = Order.GetOrders(txtCustomerName.Text);
   }
   else
   {
      orders = Order.GetOrders(Convert.ToInt32(ddlOrderStatuses.SelectedValue),
         Convert.ToDateTime(txtFromDate.Text), Convert.ToDateTime(txtToDate.Text));
   }

   gvwOrders.DataSource = orders;
   gvwOrders.DataBind();
}

Image from book
Figure 9-21

The EditOrder.aspx page defines a DetailsView control that allows users to edit a few fields of the order whose ID is passed on the querystring. The code is not presented here because it's similar to the other AddEdit{xxx}.aspx pages developed for this module (but actually simpler because most of the data is read-only, and the DetailsView's insert mode is not supported), and other modules. Figure 9-22 shows a screenshot of it, however, so that you can get an idea of what it looks like and what it can do.

Image from book
Figure 9-22

Previous Page
Next Page


JavaScript EditorFreeware javascript editor     Javascript code