Tema
Proyecto: carrito de compras
El carrito es el corazón de cualquier tienda en línea y uno de los encargos más comunes que vas a recibir como desarrollador. En este proyecto final del curso construirás uno completo: catálogo, cantidades, cupones, IVA, totales en pesos mexicanos, panel lateral accesible y persistencia. Y lo harás con funciones bien separadas, de modo que cada pieza se pueda leer, probar y cambiar sin romper las demás.
Objetivo y requisitos
Requisitos funcionales:
- Catálogo de productos local (un array en JS) con botón Agregar.
- Agregar el mismo producto suma cantidad en lugar de duplicarlo (máximo 10). Botones + y − para cambiar cantidades; llegar a 0 lo elimina. Botón Eliminar.
- Resumen con subtotal, descuento, IVA 16 % y total, formateados en MXN con
Intl.NumberFormat. - Código de descuento:
AHORRA10(10 %) yMENOS200($200 en compras desde $1,000). - Badge con el número de piezas y panel lateral que se abre y cierra (botón ✕, clic fuera o Escape).
- Persistencia en
localStoragecon validación de los datos guardados.
Requisitos técnicos: separar datos, estado, cálculos, render y eventos; montos redondeados a centavos; panel accesible con manejo de foco.
Demo final
Agrega productos, abre el carrito, cambia cantidades y prueba los cupones AHORRA10 y MENOS200. Recarga: tu carrito sigue ahí.
Estructura de archivos
txt
carrito/
├── index.html
├── styles.css
└── app.js ← datos · estado · persistencia · acciones · cálculos · render · eventosEn un proyecto grande, cada sección sería su propio módulo ES (carrito.js, calculos.js...); aquí las separamos con comentarios.
La arquitectura: capas que no se mezclan
txt
DATOS (PRODUCTOS, CUPONES) constantes, nunca cambian
ESTADO { items, cupon } lo único que cambia
ACCIONES agregarProducto()... modifican el estado
CÁLCULOS calcularTotales() funciones puras: entran datos, sale un resultado
RENDER render() estado → DOM
EVENTOS listeners usuario → acción → guardar → renderUna decisión importante: el carrito guarda sólo { id, cantidad }, no el nombre ni el precio. El precio siempre se busca en el catálogo. Así, si el precio cambia, el carrito no muestra un precio viejo, y nadie puede editar localStorage para comprar un monitor a $1.
Seguridad
Aun así, en una tienda real el servidor recalcula todo al pagar. El frontend sólo muestra; nunca confíes en precios, totales ni cupones que lleguen desde el navegador.
Paso 1: el HTML
El catálogo, la lista del carrito y el resumen empiezan vacíos: los pinta JavaScript. El panel es un <aside> con role="dialog", y el resumen usa una lista de definiciones <dl> (término y valor), que es la etiqueta semántica correcta para pares "Subtotal: $1,000".
html
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Carrito de compras</title>
<link rel="stylesheet" href="styles.css">
<script src="app.js" defer></script>
</head>
<body>
<div class="tienda" id="tienda">
<header class="barra">
<h1>Tienda Academia</h1>
<button type="button" class="btn-carrito" id="btn-abrir" aria-controls="panel" aria-expanded="false" aria-label="Abrir carrito">
🛒 <span class="badge" id="badge" hidden>0</span>
</button>
</header>
<main>
<ul class="catalogo" id="catalogo"></ul>
</main>
</div>
<div class="overlay" id="overlay"></div>
<aside class="panel" id="panel" role="dialog" aria-modal="true" aria-labelledby="titulo-panel">
<div class="panel__head">
<h2 id="titulo-panel">Tu carrito</h2>
<button type="button" class="cerrar" id="btn-cerrar" aria-label="Cerrar carrito">✕</button>
</div>
<ul class="items" id="items"></ul>
<p class="vacio" id="vacio">Tu carrito está vacío 🛍️</p>
<div class="pie">
<form class="cupon" id="form-cupon">
<label for="campo-cupon" class="sr-only">Código de descuento</label>
<input id="campo-cupon" placeholder="Código: AHORRA10" autocomplete="off">
<button type="submit" class="btn-sec">Aplicar</button>
</form>
<p class="mensaje" id="mensaje" aria-live="polite"></p>
<dl class="resumen" id="resumen"></dl>
<div class="acciones">
<button type="button" class="btn-sec" id="btn-vaciar">Vaciar</button>
<button type="button" class="btn-pri" id="btn-pagar">Pagar</button>
</div>
</div>
</aside>
</body>
</html>Paso 2: los estilos clave
El panel vive fuera de la pantalla con transform y aparece al agregar la clase abierto. Usamos visibility: hidden además del transform: así, cerrado, sus botones no se pueden enfocar con Tab. El CSS completo está en la pestaña CSS del demo.
css
.panel { position: fixed; top: 0; right: 0; bottom: 0; width: min(380px, 92vw); display: flex; flex-direction: column;
transform: translateX(100%); visibility: hidden; transition: transform .3s ease, visibility .3s; }
.panel.abierto { transform: none; visibility: visible; }
.items { flex: 1; overflow-y: auto; } /* sólo la lista hace scroll; el resumen queda fijo abajo */Paso 3: estado, dinero y acciones
El estado es sólo { items: [], cupon: null }. Para el dinero usamos new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' }): dinero.format(1499) devuelve '$1,499.00'. Pone el símbolo, las comas de miles y los dos decimales según el país. Créalo una vez y reutilízalo: crearlo en cada render es más lento.
Las acciones son pequeñas y con una sola tarea. agregarProducto(id) busca el producto en el carrito: si ya está, suma 1 (sin pasar de MAX_POR_PRODUCTO); si no, agrega { id, cantidad: 1 }. cambiarCantidad(id, delta) sirve para + (delta = 1) y − (delta = -1) y, si llega a 0, delega en eliminarProducto. aplicarCupon no muestra nada en pantalla: devuelve { ok, texto } y quien la llama decide cómo mostrarlo. Esa separación hace que la lógica no dependa del DOM.
Paso 4: cálculos con funciones puras
js
function calcularTotales(items, codigo) {
const subtotal = redondear(items.reduce((suma, i) => suma + buscarProducto(i.id).precio * i.cantidad, 0));
const descuento = calcularDescuento(subtotal, codigo);
const iva = redondear((subtotal - descuento) * IVA);
const total = redondear(subtotal - descuento + iva);
const piezas = items.reduce((suma, i) => suma + i.cantidad, 0);
return { subtotal, descuento, iva, total, piezas };
}
// Pruébala en la consola, sin tocar la interfaz:
calcularTotales([{ id: 'p1', cantidad: 2 }], 'AHORRA10');
// { subtotal: 1798, descuento: 179.8, iva: 258.91, total: 1877.11, piezas: 2 }Una función pura sólo depende de sus parámetros y no modifica nada externo: con la misma entrada, siempre la misma salida.
El orden importa: el descuento se aplica antes del IVA (el impuesto se calcula sobre lo que realmente se cobra) y el descuento nunca puede superar el subtotal (Math.min).
Error común: decimales flotantes
En JavaScript 0.1 + 0.2 da 0.30000000000000004. Con dinero, esos errores se acumulan. Aquí redondeamos a centavos en cada paso con Math.round(n * 100) / 100. En sistemas reales es aún mejor guardar los montos en centavos como enteros (89900 en lugar de 899.00) y dividir entre 100 sólo al mostrar.
Paso 5: render seguro con un helper
Crear elementos con createElement es seguro pero verboso. Un pequeño helper lo hace legible:
js
function crear(etiqueta, props = {}, ...hijos) {
const { dataset = {}, ...resto } = props;
const el = Object.assign(document.createElement(etiqueta), resto);
Object.assign(el.dataset, dataset);
el.append(...hijos);
return el;
}
// crear('button', { type: 'button', dataset: { id: 'p1' } }, 'Agregar')append con un string crea un nodo de texto, nunca HTML, así que los nombres de productos no pueden inyectar código. Es la misma idea de textContent del proyecto de tareas, con menos líneas. El catálogo se pinta una sola vez; el carrito, el resumen y el badge, en cada render().
Paso 6: eventos con delegación
Un solo listener en la lista del carrito atiende +, − y Eliminar con un objeto de acciones:
js
const acciones = {
sumar: () => cambiarCantidad(id, 1),
restar: () => cambiarCantidad(id, -1),
eliminar: () => eliminarProducto(id)
};
acciones[boton.dataset.accion]();
actualizar();Como render() destruye y recrea los botones, el foco se perdería después de cada clic. Por eso, tras repintar, buscamos el botón equivalente y lo volvemos a enfocar: quien usa teclado puede presionar + varias veces seguidas.
Paso 7: panel lateral accesible
abrirPanel()agrega las clasesabiertoyvisible, cambiaaria-expandedy hacetienda.inert = true;cerrarPanel()deshace todo.inertdeja la tienda de fondo "dormida": Tab no puede escaparse del panel.- Al abrir, el foco va al botón de cerrar; al cerrar, regresa al botón del carrito.
- Escape, el botón ✕ y el clic en el overlay cierran el panel.
- El badge "salta" con
badge.animate()(Web Animations API) y el botón del carrito actualiza suaria-label("Abrir carrito, 3 productos"), porque un lector de pantalla no "ve" el badge.
Buena práctica
Un panel lateral es un diálogo modal. Las tres reglas de oro: mover el foco al abrir, impedir que salga mientras está abierto y devolverlo al cerrar. Repásalas en el modal de la lección de Eventos.
Código completo: app.js
El HTML está completo en el paso 1 y el CSS en la pestaña CSS del demo. Este es el JavaScript final, idéntico al del demo:
js
// ===== Datos y configuración =====
const PRODUCTOS = [
{ id: 'p1', nombre: 'Audífonos Bluetooth', precio: 899, emoji: '🎧' },
{ id: 'p2', nombre: 'Teclado mecánico', precio: 1499, emoji: '⌨️' },
{ id: 'p3', nombre: 'Mouse inalámbrico', precio: 349, emoji: '🖱️' },
{ id: 'p4', nombre: 'Monitor 24 pulgadas', precio: 3299, emoji: '🖥️' },
{ id: 'p5', nombre: 'Webcam HD', precio: 749, emoji: '📷' },
{ id: 'p6', nombre: 'Bocina portátil', precio: 599, emoji: '🔊' }
];
const CUPONES = {
AHORRA10: { tipo: 'porcentaje', valor: 10, etiqueta: '10% de descuento' },
MENOS200: { tipo: 'fijo', valor: 200, minimo: 1000, etiqueta: '$200 en compras desde $1,000' }
};
const IVA = 0.16;
const MAX_POR_PRODUCTO = 10;
const CLAVE = 'academia:carrito';
const dinero = new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' });
// ===== Estado =====
const estado = { items: [], cupon: null }; // items: [{ id, cantidad }]
// ===== DOM =====
const $ = (selector) => document.querySelector(selector);
const tienda = $('#tienda'), catalogo = $('#catalogo'), badge = $('#badge');
const btnAbrir = $('#btn-abrir'), btnCerrar = $('#btn-cerrar'), panel = $('#panel'), overlay = $('#overlay');
const listaItems = $('#items'), vacio = $('#vacio'), resumen = $('#resumen'), mensaje = $('#mensaje');
const formCupon = $('#form-cupon'), campoCupon = $('#campo-cupon');
const btnVaciar = $('#btn-vaciar'), btnPagar = $('#btn-pagar');
// ===== Utilidades =====
const redondear = (n) => Math.round(n * 100) / 100;
const buscarProducto = (id) => PRODUCTOS.find((p) => p.id === id);
const buscarItem = (id) => estado.items.find((i) => i.id === id);
// Crea un elemento con propiedades e hijos (los textos se insertan como texto, nunca como HTML)
function crear(etiqueta, props = {}, ...hijos) {
const { dataset = {}, ...resto } = props;
const el = Object.assign(document.createElement(etiqueta), resto);
Object.assign(el.dataset, dataset);
el.append(...hijos);
return el;
}
// ===== Persistencia =====
function cargarCarrito() {
try {
const datos = JSON.parse(localStorage.getItem(CLAVE)) ?? {};
const items = Array.isArray(datos.items) ? datos.items : [];
// Validamos: sólo productos que existen y cantidades válidas
estado.items = items.filter((i) => buscarProducto(i.id) && Number.isInteger(i.cantidad) && i.cantidad > 0);
estado.cupon = CUPONES[datos.cupon] ? datos.cupon : null;
} catch (error) {
console.warn('Carrito guardado inválido, empezamos vacío', error);
}
}
function guardarCarrito() {
try {
localStorage.setItem(CLAVE, JSON.stringify({ items: estado.items, cupon: estado.cupon }));
} catch (error) {
console.warn('No se pudo guardar el carrito', error);
}
}
// ===== Acciones del carrito (sólo cambian el estado) =====
function agregarProducto(id) {
const item = buscarItem(id);
if (item) item.cantidad = Math.min(item.cantidad + 1, MAX_POR_PRODUCTO);
else estado.items.push({ id, cantidad: 1 });
}
function cambiarCantidad(id, delta) {
const item = buscarItem(id);
if (!item) return;
item.cantidad = Math.min(item.cantidad + delta, MAX_POR_PRODUCTO);
if (item.cantidad <= 0) eliminarProducto(id);
}
function eliminarProducto(id) {
estado.items = estado.items.filter((i) => i.id !== id);
}
function vaciarCarrito() {
estado.items = [];
estado.cupon = null;
}
function aplicarCupon(texto) {
const codigo = texto.trim().toUpperCase();
const cupon = CUPONES[codigo];
if (!cupon) return { ok: false, texto: `El código "${codigo}" no es válido.` };
estado.cupon = codigo;
return { ok: true, texto: `Cupón ${codigo} aplicado: ${cupon.etiqueta}.` };
}
// ===== Cálculos (funciones puras: no tocan estado ni DOM) =====
function calcularDescuento(subtotal, codigo) {
const cupon = CUPONES[codigo];
if (!cupon || subtotal < (cupon.minimo ?? 0)) return 0;
const monto = cupon.tipo === 'porcentaje' ? subtotal * cupon.valor / 100 : cupon.valor;
return redondear(Math.min(monto, subtotal));
}
function calcularTotales(items, codigo) {
const subtotal = redondear(items.reduce((suma, i) => suma + buscarProducto(i.id).precio * i.cantidad, 0));
const descuento = calcularDescuento(subtotal, codigo);
const iva = redondear((subtotal - descuento) * IVA);
const total = redondear(subtotal - descuento + iva);
const piezas = items.reduce((suma, i) => suma + i.cantidad, 0);
return { subtotal, descuento, iva, total, piezas };
}
// ===== Render =====
function renderCatalogo() {
catalogo.replaceChildren(...PRODUCTOS.map((p) => crear('li', { className: 'producto' },
crear('div', { className: 'producto__img', ariaHidden: 'true' }, p.emoji),
crear('span', { className: 'producto__nombre' }, p.nombre),
crear('span', { className: 'producto__precio' }, dinero.format(p.precio)),
crear('button', { type: 'button', className: 'btn-pri', dataset: { id: p.id }, ariaLabel: `Agregar ${p.nombre}` }, 'Agregar')
)));
}
function crearItem({ id, cantidad }) {
const p = buscarProducto(id);
return crear('li', { className: 'item', dataset: { id } },
crear('span', { className: 'item__img', ariaHidden: 'true' }, p.emoji),
crear('span', { className: 'item__nombre' }, p.nombre),
crear('span', { className: 'item__total' }, dinero.format(p.precio * cantidad)),
crear('div', { className: 'cantidad' },
crear('button', { type: 'button', dataset: { accion: 'restar' }, ariaLabel: `Quitar uno de ${p.nombre}` }, '−'),
crear('output', { ariaLabel: 'Cantidad' }, String(cantidad)),
crear('button', { type: 'button', dataset: { accion: 'sumar' }, ariaLabel: `Agregar uno de ${p.nombre}`, disabled: cantidad >= MAX_POR_PRODUCTO }, '+')
),
crear('button', { type: 'button', className: 'quitar', dataset: { accion: 'eliminar' }, ariaLabel: `Eliminar ${p.nombre}` }, 'Eliminar')
);
}
function filaResumen(etiqueta, valor, clase = '') {
return [crear('dt', { className: clase }, etiqueta), crear('dd', { className: clase }, valor)];
}
function renderResumen(t) {
const filas = [filaResumen('Subtotal', dinero.format(t.subtotal))];
if (estado.cupon) {
const minimo = CUPONES[estado.cupon].minimo;
const valor = t.descuento > 0 ? `−${dinero.format(t.descuento)}` : `Mínimo ${dinero.format(minimo)}`;
filas.push(filaResumen(`Descuento (${estado.cupon})`, valor));
}
filas.push(filaResumen('IVA (16%)', dinero.format(t.iva)));
filas.push(filaResumen('Total', dinero.format(t.total), 'total'));
resumen.replaceChildren(...filas.flat());
}
function renderBadge(piezas) {
const anterior = Number(badge.textContent);
badge.textContent = piezas;
badge.hidden = piezas === 0;
btnAbrir.setAttribute('aria-label', `Abrir carrito, ${piezas} ${piezas === 1 ? 'producto' : 'productos'}`);
if (piezas > anterior) badge.animate([{ transform: 'scale(1.4)' }, { transform: 'scale(1)' }], 350); // "salto"
}
function render() {
const totales = calcularTotales(estado.items, estado.cupon);
listaItems.replaceChildren(...estado.items.map(crearItem));
vacio.hidden = estado.items.length > 0;
renderResumen(totales);
renderBadge(totales.piezas);
btnPagar.disabled = btnVaciar.disabled = estado.items.length === 0;
}
function actualizar() {
guardarCarrito();
render();
}
function mostrarMensaje(texto, tipo) {
mensaje.textContent = texto;
mensaje.className = `mensaje ${tipo}`;
}
// ===== Panel lateral =====
function abrirPanel() {
panel.classList.add('abierto');
overlay.classList.add('visible');
btnAbrir.setAttribute('aria-expanded', 'true');
tienda.inert = true; // el fondo no recibe foco ni clics
btnCerrar.focus();
}
function cerrarPanel() {
panel.classList.remove('abierto');
overlay.classList.remove('visible');
btnAbrir.setAttribute('aria-expanded', 'false');
tienda.inert = false;
btnAbrir.focus(); // devolvemos el foco a donde estaba
}
// ===== Eventos =====
catalogo.addEventListener('click', (e) => {
const boton = e.target.closest('button[data-id]');
if (!boton) return;
agregarProducto(boton.dataset.id);
actualizar();
boton.textContent = '✓ Agregado';
setTimeout(() => { boton.textContent = 'Agregar'; }, 900);
});
listaItems.addEventListener('click', (e) => {
const boton = e.target.closest('button[data-accion]');
if (!boton) return;
const id = boton.closest('.item').dataset.id;
const acciones = {
sumar: () => cambiarCantidad(id, 1),
restar: () => cambiarCantidad(id, -1),
eliminar: () => eliminarProducto(id)
};
acciones[boton.dataset.accion]();
actualizar();
// tras repintar, devolvemos el foco al mismo botón para poder seguir con el teclado
listaItems.querySelector(`[data-id="${id}"] [data-accion="${boton.dataset.accion}"]`)?.focus();
});
formCupon.addEventListener('submit', (e) => {
e.preventDefault();
const resultado = aplicarCupon(campoCupon.value);
mostrarMensaje(resultado.texto, resultado.ok ? 'ok' : 'error');
if (resultado.ok) campoCupon.value = '';
actualizar();
});
btnVaciar.addEventListener('click', () => {
vaciarCarrito();
mostrarMensaje('Carrito vacío.', 'ok');
actualizar();
});
btnPagar.addEventListener('click', () => {
const { total } = calcularTotales(estado.items, estado.cupon);
vaciarCarrito();
mostrarMensaje(`¡Gracias! Pagaste ${dinero.format(total)} (es una demo).`, 'ok');
actualizar();
});
btnAbrir.addEventListener('click', abrirPanel);
btnCerrar.addEventListener('click', cerrarPanel);
overlay.addEventListener('click', cerrarPanel);
document.addEventListener('keydown', (e) => e.key === 'Escape' && panel.classList.contains('abierto') && cerrarPanel());
// ===== Inicio =====
cargarCarrito();
renderCatalogo();
render();Decisiones de diseño
| Decisión | Por qué |
|---|---|
El carrito guarda sólo id y cantidad | Los precios vienen siempre del catálogo; no hay datos desactualizados ni manipulables. |
| Cálculos en funciones puras | Fáciles de probar y reutilizar (por ejemplo, en el resumen del checkout). |
| Validar lo que se lee del storage | Productos que ya no existen o cantidades raras se descartan sin romper la app. |
Helper crear() con nodos de texto | Render legible y sin riesgo de XSS. |
inert y manejo de foco | Panel usable con teclado y lector de pantalla. |
Retos de mejora
- Quitar cupón: muestra un botón junto al descuento para quitar el cupón aplicado.
- Sincronizar pestañas: que el carrito se actualice si lo cambias en otra pestaña.
Ver solución
js
// 1. Quitar cupón: en renderResumen, dentro de if (estado.cupon) { ... }
const quitar = crear('button', { type: 'button', className: 'quitar', dataset: { accion: 'quitar-cupon' } }, 'Quitar');
filas.push([crear('dt', {}, quitar), crear('dd')]);
resumen.addEventListener('click', (e) => {
if (!e.target.closest('[data-accion="quitar-cupon"]')) return;
estado.cupon = null;
mostrarMensaje('Cupón eliminado.', 'ok');
actualizar();
});
// 2. Sincronizar pestañas
window.addEventListener('storage', (e) => {
if (e.key !== CLAVE) return;
cargarCarrito(); // vuelve a leer y validar
render(); // sin guardar: evitamos un bucle entre pestañas
});Resumen
- Separa tu código en capas: datos, estado, acciones, cálculos, render y eventos.
- Guarda en el carrito sólo referencias (
id,cantidad) y busca precios en el catálogo. - Escribe los cálculos como funciones puras: aplica el descuento antes del IVA y redondea a centavos.
- Valida siempre lo que lees de
localStorage, dentro detry/catch. - Un panel lateral es un modal: maneja el foco, usa
inerty permite cerrar con Escape. - El frontend muestra los totales, pero el servidor es quien cobra y valida.
¡Terminaste el curso de JavaScript! 🎉 Ya sabes construir aplicaciones interactivas reales. El siguiente paso natural es agregar tipos a tu código para detectar errores antes de ejecutarlo.
Siguiente: TypeScript