Compartir vía


Parte 8: Carro de la compra con las actualizaciones de Ajax

por Jon Galloway

MVC Music Store es una aplicación de tutorial que presenta y explica paso a paso cómo usar ASP.NET MVC y Visual Studio para el desarrollo web.

MVC Music Store es una implementación ligera de la tienda de muestras que vende álbumes de música en línea e implementa la administración básica del sitio, el inicio de sesión de usuario y la funcionalidad del carro de la compra.

En esta serie de tutoriales se detallan todos los pasos realizados para compilar la aplicación de ejemplo ASP.NET MVC Music Store. La parte 8 cubre el carro de la compra con las actualizaciones de Ajax.

Permitiremos que los usuarios coloquen álbumes en su carro sin registrarse, pero tendrán que registrarse como invitados para completar la verificación. El proceso de compra y verificación se separará en dos controladores: un controlador de ShoppingCart que permite agregar elementos de forma anónima a un carro y un controlador de verificación que controla el proceso de verificación. Comenzaremos con el carro de la compra en esta sección y, a continuación, crearemos el proceso de verificación en la sección siguiente.

Adición de las clases modelo Cart, Order y OrderDetail

Nuestros procesos de Carro de compra y Verificación usan algunas clases nuevas. Haz clic con el botón derecho en la carpeta Modelos y agrega una clase Cart (Cart.cs) con el código siguiente.

using System.ComponentModel.DataAnnotations;
 
namespace MvcMusicStore.Models
{
    public class Cart
    {
        [Key]
        public int      RecordId    { get; set; }
        public string   CartId      { get; set; }
        public int      AlbumId     { get; set; }
        public int      Count       { get; set; }
        public System.DateTime DateCreated { get; set; }
        public virtual Album Album  { get; set; }
    }
}

Esta clase es bastante similar a otras que hemos usado hasta ahora, con la excepción del atributo [Key] para la propiedad RecordId. Nuestros artículos del carro tendrán un identificador de cadena denominado CartID para permitir compras anónimas, pero la tabla incluye una clave principal entera denominada RecordId. Por convención, Entity Framework Code-First espera que la clave principal de una tabla denominada Cart sea CartId o ID, pero podemos invalidar fácilmente eso a través de anotaciones o el código si queremos. Este es un ejemplo de cómo podemos usar las convenciones simples en Entity Framework Code-First cuando se adaptan a nosotros, pero no estamos restringidos por ellas cuando no lo hacen.

A continuación, agrega una clase Order (Order.cs) con el código siguiente.

using System.Collections.Generic;
 
namespace MvcMusicStore.Models
{
    public partial class Order
    {
        public int    OrderId    { get; set; }
        public string Username   { get; set; }
        public string FirstName  { get; set; }
        public string LastName   { get; set; }
        public string Address    { get; set; }
        public string City       { get; set; }
        public string State      { get; set; }
        public string PostalCode { get; set; }
        public string Country    { get; set; }
        public string Phone      { get; set; }
        public string Email      { get; set; }
        public decimal Total     { get; set; }
        public System.DateTime OrderDate      { get; set; }
        public List<OrderDetail> OrderDetails { get; set; }
    }
}

Esta clase realiza un seguimiento de la información de resumen y entrega de un pedido. Aún no se compilará, ya que tiene una propiedad de navegación OrderDetails que depende de una clase que aún no se ha creado. Vamos a corregirlo ahora agregando una clase denominada OrderDetail.cs, agregando el código siguiente.

namespace MvcMusicStore.Models
{
    public class OrderDetail
    {
        public int OrderDetailId { get; set; }
        public int OrderId { get; set; }
        public int AlbumId { get; set; }
        public int Quantity { get; set; }
        public decimal UnitPrice { get; set; }
        public virtual Album Album { get; set; }
        public virtual Order Order { get; set; }
    }
}

Realizaremos una última actualización a nuestra clase MusicStoreEntities para incluir DbSets que exponga esas nuevas clases modelo, incluido también un DbSet<Artist>. La clase MusicStoreEntities actualizada aparece como se muestra a continuación.

using System.Data.Entity;
 
namespace MvcMusicStore.Models
{
    public class MusicStoreEntities : DbContext
    {
        public DbSet<Album>     Albums  { get; set; }
        public DbSet<Genre>     Genres  { get; set; }
        public DbSet<Artist>    Artists {
get; set; }
        public DbSet<Cart>     
Carts { get; set; }
        public DbSet<Order>     Orders
{ get; set; }
        public DbSet<OrderDetail>
OrderDetails { get; set; }
    }
}

Administración de la lógica de negocios del carro de la compra

A continuación, crearemos la clase ShoppingCart en la carpeta Modelos. El modelo ShoppingCart controla el acceso a los datos a la tabla Cart. Además, controlará la lógica de negocios para agregar y quitar elementos del carro de la compra.

Dado que no queremos que los usuarios se registren en una cuenta solo para agregar elementos a su carro de la compra, asignaremos a los usuarios un identificador único temporal (mediante un GUID o un identificador único global) cuando accedan al carro de la compra. Almacenaremos este identificador mediante la clase Session de ASP.NET.

Nota: Session de ASP.NET es un lugar conveniente para almacenar información específica del usuario que expirará después de salir del sitio. Aunque el uso incorrecto del estado de sesión puede tener implicaciones de rendimiento en sitios más grandes, nuestro uso ligero funcionará bien con fines de demostración.

La clase ShoppingCart expone los métodos siguientes:

AddToCart toma un álbum como parámetro y lo agrega al carro del usuario. Puesto que la tabla Cart realiza un seguimiento de la cantidad de cada álbum, incluye lógica para crear una nueva fila si es necesario o simplemente incrementar la cantidad si el usuario ya ha pedido una copia del álbum.

RemoveFromCart coge un identificador de álbum y lo quita del carro del usuario. Si el usuario solo tenía una copia del álbum en su carro, se elimina la fila.

EmptyCart elimina todos los elementos del carro de la compra de un usuario.

GetCartItems recupera una lista de CartItems (objetos del carro) para mostrar o procesar.

GetCount recupera un número total de álbumes que un usuario tiene en su carro de la compra.

GetTotal calcula el costo total de todos los artículos del carro.

CreateOrder convierte el carro de la compra en un pedido durante la fase de verificación.

GetCart es un método estático que permite a nuestros controladores obtener un objeto del carro. Usa el método GetCartId para controlar la lectura de CartId desde la sesión del usuario. El método GetCartId requiere HttpContextBase para poder leer el valor de CartId del usuario desde la sesión del usuario.

Esta es la clase ShoppingCart completa:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
 
namespace MvcMusicStore.Models
{
    public partial class ShoppingCart
    {
        MusicStoreEntities storeDB = new MusicStoreEntities();
        string ShoppingCartId { get; set; }
        public const string CartSessionKey = "CartId";
        public static ShoppingCart GetCart(HttpContextBase context)
        {
            var cart = new ShoppingCart();
            cart.ShoppingCartId = cart.GetCartId(context);
            return cart;
        }
        // Helper method to simplify shopping cart calls
        public static ShoppingCart GetCart(Controller controller)
        {
            return GetCart(controller.HttpContext);
        }
        public void AddToCart(Album album)
        {
            // Get the matching cart and album instances
            var cartItem = storeDB.Carts.SingleOrDefault(
                c => c.CartId == ShoppingCartId 
                && c.AlbumId == album.AlbumId);
 
            if (cartItem == null)
            {
                // Create a new cart item if no cart item exists
                cartItem = new Cart
                {
                    AlbumId = album.AlbumId,
                    CartId = ShoppingCartId,
                    Count = 1,
                    DateCreated = DateTime.Now
                };
                storeDB.Carts.Add(cartItem);
            }
            else
            {
                // If the item does exist in the cart, 
                // then add one to the quantity
                cartItem.Count++;
            }
            // Save changes
            storeDB.SaveChanges();
        }
        public int RemoveFromCart(int id)
        {
            // Get the cart
            var cartItem = storeDB.Carts.Single(
                cart => cart.CartId == ShoppingCartId 
                && cart.RecordId == id);
 
            int itemCount = 0;
 
            if (cartItem != null)
            {
                if (cartItem.Count > 1)
                {
                    cartItem.Count--;
                    itemCount = cartItem.Count;
                }
                else
                {
                    storeDB.Carts.Remove(cartItem);
                }
                // Save changes
                storeDB.SaveChanges();
            }
            return itemCount;
        }
        public void EmptyCart()
        {
            var cartItems = storeDB.Carts.Where(
                cart => cart.CartId == ShoppingCartId);
 
            foreach (var cartItem in cartItems)
            {
                storeDB.Carts.Remove(cartItem);
            }
            // Save changes
            storeDB.SaveChanges();
        }
        public List<Cart> GetCartItems()
        {
            return storeDB.Carts.Where(
                cart => cart.CartId == ShoppingCartId).ToList();
        }
        public int GetCount()
        {
            // Get the count of each item in the cart and sum them up
            int? count = (from cartItems in storeDB.Carts
                          where cartItems.CartId == ShoppingCartId
                          select (int?)cartItems.Count).Sum();
            // Return 0 if all entries are null
            return count ?? 0;
        }
        public decimal GetTotal()
        {
            // Multiply album price by count of that album to get 
            // the current price for each of those albums in the cart
            // sum all album price totals to get the cart total
            decimal? total = (from cartItems in storeDB.Carts
                              where cartItems.CartId == ShoppingCartId
                              select (int?)cartItems.Count *
                              cartItems.Album.Price).Sum();

            return total ?? decimal.Zero;
        }
        public int CreateOrder(Order order)
        {
            decimal orderTotal = 0;
 
            var cartItems = GetCartItems();
            // Iterate over the items in the cart, 
            // adding the order details for each
            foreach (var item in cartItems)
            {
                var orderDetail = new OrderDetail
                {
                    AlbumId = item.AlbumId,
                    OrderId = order.OrderId,
                    UnitPrice = item.Album.Price,
                    Quantity = item.Count
                };
                // Set the order total of the shopping cart
                orderTotal += (item.Count * item.Album.Price);
 
                storeDB.OrderDetails.Add(orderDetail);
 
            }
            // Set the order's total to the orderTotal count
            order.Total = orderTotal;
 
            // Save the order
            storeDB.SaveChanges();
            // Empty the shopping cart
            EmptyCart();
            // Return the OrderId as the confirmation number
            return order.OrderId;
        }
        // We're using HttpContextBase to allow access to cookies.
        public string GetCartId(HttpContextBase context)
        {
            if (context.Session[CartSessionKey] == null)
            {
                if (!string.IsNullOrWhiteSpace(context.User.Identity.Name))
                {
                    context.Session[CartSessionKey] =
                        context.User.Identity.Name;
                }
                else
                {
                    // Generate a new random GUID using System.Guid class
                    Guid tempCartId = Guid.NewGuid();
                    // Send tempCartId back to client as a cookie
                    context.Session[CartSessionKey] = tempCartId.ToString();
                }
            }
            return context.Session[CartSessionKey].ToString();
        }
        // When a user has logged in, migrate their shopping cart to
        // be associated with their username
        public void MigrateCart(string userName)
        {
            var shoppingCart = storeDB.Carts.Where(
                c => c.CartId == ShoppingCartId);
 
            foreach (Cart item in shoppingCart)
            {
                item.CartId = userName;
            }
            storeDB.SaveChanges();
        }
    }
}

ViewModels

Nuestro controlador de carro de la compra deberá comunicar información compleja a sus vistas que no se asignan limpiamente a nuestros objetos Modelo. No queremos modificar nuestros modelos para adaptarnos a nuestras vistas; las clases de modelo deben representar nuestro dominio, no la interfaz de usuario. Una solución sería pasar la información a nuestras vistas mediante la clase ViewBag, como hicimos con la información desplegable del Administrador de la tienda, pero pasar una gran cantidad de información a través de ViewBag resulta difícil de administrar.

Una solución a esto consiste en usar el patrón ViewModel. Cuando se usa este patrón, se crean clases fuertemente tipadas que están optimizadas para escenarios de vista concretos y que exponen propiedades para los valores dinámicos y el contenido que necesitan las plantillas de vista. Las clases de controlador pueden rellenar y pasar estas clases optimizadas para vistas a la plantilla de vista para que esta las use, lo que aporta seguridad de tipo, comprobación en tiempo de compilación y funcionalidad IntelliSense del editor a las plantillas de vista.

Crearemos dos modelos de vista para su uso en nuestro controlador de carro de la compra: ShoppingCartViewModel contendrá el contenido del carro de la compra del usuario y el ShoppingCartRemoveViewModel se usará para mostrar información de confirmación cuando un usuario quite algo de su carro.

Vamos a crear una nueva carpeta ViewModels en la raíz de nuestro proyecto para mantener las cosas organizadas. Haz clic con el botón derecho en el proyecto y selecciona Agregar o Nueva carpeta.

Screenshot of the project window showing the right-click menu with the Add and New Folder options highlighted in yellow.

Asigna a la nueva carpeta el nombre ViewModels.

Screenshot of the Solution Explorer showing the newly created and newly named folder, View Models, highlighted with a black box.

A continuación, agrega la clase ShoppingCartViewModel en la carpeta ViewModels. Tiene dos propiedades: una lista de artículos del carro y un valor decimal para contener el precio total de todos los artículos del carro.

using System.Collections.Generic;
using MvcMusicStore.Models;
 
namespace MvcMusicStore.ViewModels
{
    public class ShoppingCartViewModel
    {
        public List<Cart> CartItems { get; set; }
        public decimal CartTotal { get; set; }
    }
}

Ahora agrega ShoppingCartRemoveViewModel a la carpeta ViewModels, con las cuatro propiedades siguientes.

namespace MvcMusicStore.ViewModels
{
    public class ShoppingCartRemoveViewModel
    {
        public string Message { get; set; }
        public decimal CartTotal { get; set; }
        public int CartCount { get; set; }
        public int ItemCount { get; set; }
        public int DeleteId { get; set; }
    }
}

Controlador de carro de la compra

El controlador de carro de la compra tiene tres propósitos principales: agregar elementos a un carro, eliminar elementos del carro y ver los elementos en el carro. Usará las tres clases que acabamos de crear: ShoppingCartViewModel, ShoppingCartRemoveViewModel y ShoppingCart. Como en StoreController y StoreManagerController, agregaremos un campo para contener una instancia de MusicStoreEntities.

Agrega un nuevo controlador de carro de la compra al proyecto mediante la plantilla Empty controller (Controlador vacío).

Screenshot of the Add Controller window with Shopping Cart Controller in the Controller name field and highlighted in blue.

Este es el controlador de ShoppingCart completo. Las acciones Index y Add Controller deben resultar familiares. Las acciones de controlador Remove y CartSummary controlan dos casos especiales, que analizaremos en la sección siguiente.

using System.Linq;
using System.Web.Mvc;
using MvcMusicStore.Models;
using MvcMusicStore.ViewModels;
 
namespace MvcMusicStore.Controllers
{
    public class ShoppingCartController : Controller
    {
        MusicStoreEntities storeDB = new MusicStoreEntities();
        //
        // GET: /ShoppingCart/
        public ActionResult Index()
        {
            var cart = ShoppingCart.GetCart(this.HttpContext);
 
            // Set up our ViewModel
            var viewModel = new ShoppingCartViewModel
            {
                CartItems = cart.GetCartItems(),
                CartTotal = cart.GetTotal()
            };
            // Return the view
            return View(viewModel);
        }
        //
        // GET: /Store/AddToCart/5
        public ActionResult AddToCart(int id)
        {
            // Retrieve the album from the database
            var addedAlbum = storeDB.Albums
                .Single(album => album.AlbumId == id);
 
            // Add it to the shopping cart
            var cart = ShoppingCart.GetCart(this.HttpContext);
 
            cart.AddToCart(addedAlbum);
 
            // Go back to the main store page for more shopping
            return RedirectToAction("Index");
        }
        //
        // AJAX: /ShoppingCart/RemoveFromCart/5
        [HttpPost]
        public ActionResult RemoveFromCart(int id)
        {
            // Remove the item from the cart
            var cart = ShoppingCart.GetCart(this.HttpContext);
 
            // Get the name of the album to display confirmation
            string albumName = storeDB.Carts
                .Single(item => item.RecordId == id).Album.Title;
 
            // Remove from cart
            int itemCount = cart.RemoveFromCart(id);
 
            // Display the confirmation message
            var results = new ShoppingCartRemoveViewModel
            {
                Message = Server.HtmlEncode(albumName) +
                    " has been removed from your shopping cart.",
                CartTotal = cart.GetTotal(),
                CartCount = cart.GetCount(),
                ItemCount = itemCount,
                DeleteId = id
            };
            return Json(results);
        }
        //
        // GET: /ShoppingCart/CartSummary
        [ChildActionOnly]
        public ActionResult CartSummary()
        {
            var cart = ShoppingCart.GetCart(this.HttpContext);
 
            ViewData["CartCount"] = cart.GetCount();
            return PartialView("CartSummary");
        }
    }
}

Actualizaciones de Ajax con jQuery

A continuación, crearemos una página de índice de carro de la compra fuertemente tipada en ShoppingCartViewModel y usaremos la plantilla Vista de lista con el mismo método que antes.

Screenshot of the Add View window showing the View name field, the View engine, Model Class, and Scaffold dropdowns, and the Use a layout file picker.

Sin embargo, en lugar de usar html.ActionLink para quitar elementos del carro, usaremos jQuery para "conectar" el evento clic para todos los vínculos de esta vista que tienen la clase HTML RemoveLink. En lugar de publicar el formulario, este controlador de eventos clic simplemente realizará una devolución de llamada AJAX a nuestra acción de controlador RemoveFromCart. RemoveFromCart devuelve un resultado serializado JSON, que la devolución de llamada de jQuery analiza y realiza cuatro actualizaciones rápidas en la página mediante jQuery:

    1. Quitar el álbum eliminado de la lista.
    1. Actualizar el recuento de carros en el encabezado.
    1. Mostrar un mensaje de actualización al usuario.
    1. Actualizar el precio total del carro.

Dado que el escenario de eliminación se controla mediante una devolución de llamada de Ajax dentro de la vista Índice, no necesitamos una vista adicional para la acción RemoveFromCart. Este es el código completo de la vista /ShoppingCart/Index:

@model MvcMusicStore.ViewModels.ShoppingCartViewModel
@{
    ViewBag.Title = "Shopping Cart";
}
<script src="/Scripts/jquery-1.4.4.min.js"
type="text/javascript"></script>
<script type="text/javascript">
    $(function () {
        // Document.ready -> link up remove event handler
        $(".RemoveLink").click(function () {
            // Get the id from the link
            var recordToDelete = $(this).attr("data-id");
            if (recordToDelete != '') {
                // Perform the ajax post
                $.post("/ShoppingCart/RemoveFromCart", {"id": recordToDelete },
                    function (data) {
                        // Successful requests get here
                        // Update the page elements
                        if (data.ItemCount == 0) {
                            $('#row-' + data.DeleteId).fadeOut('slow');
                        } else {
                            $('#item-count-' + data.DeleteId).text(data.ItemCount);
                        }
                        $('#cart-total').text(data.CartTotal);
                        $('#update-message').text(data.Message);
                        $('#cart-status').text('Cart (' + data.CartCount + ')');
                    });
            }
        });
    });
</script>
<h3>
    <em>Review</em> your cart:
 </h3>
<p class="button">
    @Html.ActionLink("Checkout
>>", "AddressAndPayment", "Checkout")
</p>
<div id="update-message">
</div>
<table>
    <tr>
        <th>
            Album Name
        </th>
        <th>
            Price (each)
        </th>
        <th>
            Quantity
        </th>
        <th></th>
    </tr>
    @foreach (var item in
Model.CartItems)
    {
        <tr id="row-@item.RecordId">
            <td>
                @Html.ActionLink(item.Album.Title,
"Details", "Store", new { id = item.AlbumId }, null)
            </td>
            <td>
                @item.Album.Price
            </td>
            <td id="item-count-@item.RecordId">
                @item.Count
            </td>
            <td>
                <a href="#" class="RemoveLink"
data-id="@item.RecordId">Remove
from cart</a>
            </td>
        </tr>
    }
    <tr>
        <td>
            Total
        </td>
        <td>
        </td>
        <td>
        </td>
        <td id="cart-total">
            @Model.CartTotal
        </td>
    </tr>
</table>

Para probar esto, es necesario poder agregar artículos a nuestro carro de la compra. Actualizaremos la vista Detalles de la tienda para incluir un botón "Agregar al carro". Mientras estamos en él, podemos incluir parte de la información adicional del álbum que hemos agregado desde la última actualización de esta vista: género, artista, precio y arte del álbum. El código actualizado de la vista Detalles de la tienda aparece como se muestra a continuación.

@model MvcMusicStore.Models.Album
@{
    ViewBag.Title = "Album - " + Model.Title;
 }
<h2>@Model.Title</h2>
<p>
    <img alt="@Model.Title"
src="@Model.AlbumArtUrl" />
</p>
<div id="album-details">
    <p>
        <em>Genre:</em>
        @Model.Genre.Name
    </p>
    <p>
        <em>Artist:</em>
        @Model.Artist.Name
    </p>
    <p>
        <em>Price:</em>
        @String.Format("{0:F}",
Model.Price)
    </p>
    <p class="button">
        @Html.ActionLink("Add to
cart", "AddToCart", 
        "ShoppingCart", new { id = Model.AlbumId }, "")
    </p>
</div>

Ahora podemos hacer clic en la tienda y probar la adición y eliminación de álbumes hacia y desde nuestro carro de la compra. Ejecuta la aplicación y ve al Índice de la tienda.

Screenshot of the Music Store window showing the genre details defined from all album data entered into the database.

A continuación, haz clic en un género para ver una lista de álbumes.

Screenshot of the Music Store window showing the list of albums associated with the Disco genre in the album database.

Al hacer clic en un título de álbum, se muestra la vista Detalles del álbum actualizada, incluido el botón "Agregar al carro".

Screenshot of the Music Store window showing the updated Album Details view and the Add to cart button.

Al hacer clic en el botón "Agregar al carro" se muestra la vista Índice del carro de la compra con la lista resumen del carro de la compra.

Screenshot of the Music Store window showing the Shopping Cart view with a summary list of all items in the cart.

Después de cargar el carro de la compra, puedes hacer clic en el vínculo Quitar del carro para ver la actualización de Ajax al carro de la compra.

Screenshot of the Music Store window showing the Shopping Cart view with the album removed from the summary list.

Hemos creado un carro de la compra en funcionamiento que permite a los usuarios no registrados agregar elementos a su carro. En la sección siguiente, les permitiremos registrar y completar el proceso de desprotección.