Tema
Tienda con carrito en JS
Una tienda en línea sin servidor ni base de datos: catálogo con categorías, búsqueda y orden; carrito lateral con cantidades, cupones, envío gratis a partir de cierto monto, total en pesos y persistencia en localStorage. Al finalizar, el pedido completo se envía por WhatsApp al vendedor.
Es perfecta para emprendedores que venden por redes sociales y todavía no necesitan pagos en línea: se publica gratis en Netlify o GitHub Pages y se actualiza editando una lista de productos.
Vista previa
Prueba: agrega productos, cambia cantidades, aplica el cupón BIENVENIDA10, recarga la página (el carrito se conserva) y pulsa "Finalizar pedido".
Funciones incluidas
- Catálogo generado desde un arreglo
PRODUCTOS. - Filtro por categoría, buscador que ignora acentos y orden por precio, nombre o más vendidos.
- Etiquetas de oferta, precio anterior tachado, calificación y productos agotados.
- Carrito lateral con capa oscura, cierre con
Esc, bloqueo del scroll de fondo. - Sumar, restar y eliminar, respetando el stock disponible.
- Barra de progreso hacia el envío gratis.
- Cupones de porcentaje o monto fijo con compra mínima.
- Persistencia en
localStoragecontry/catchy sincronización entre pestañas. - Total formateado con
Intl.NumberFormat('es-MX'). - Pedido por WhatsApp con detalle, descuento, envío y total.
Configuración
Todo lo editable está al inicio del script:
js
const WHATSAPP = '5215512345678'; // número del vendedor (52 + 1 + 10 dígitos o 52 + 10)
const ENVIO_GRATIS_DESDE = 899; // monto para envío gratis
const COSTO_ENVIO = 129;
const CLAVE_STORAGE = 'barroygrano_carrito_v1';
const CUPONES = {
BIENVENIDA10: { tipo: 'porcentaje', valor: 10 },
CAFE50: { tipo: 'monto', valor: 50, minimo: 500 },
};Productos
js
const PRODUCTOS = [
{
id: 1, // único y numérico
nombre: 'Café Chiapas Altura 500 g',
cat: 'Café', // las categorías se generan solas
precio: 229,
antes: null, // precio anterior (para mostrar oferta) o null
img: 'cafe-chiapas', // en la demo: semilla de picsum.photos
rating: 4.9, resenas: 214,
stock: 30, // 0 = agotado
etiqueta: 'Más vendido', // texto del distintivo o null
},
];Para usar fotos reales, cambia la función urlImg:
js
const urlImg = (p) => `img/productos/${p.img}.webp`;Cómo funciona el carrito
Estado mínimo
El carrito guarda sólo los ids y cantidades, nunca precios. Así, si cambias un precio en PRODUCTOS, el carrito guardado del cliente muestra el precio nuevo:
js
// carrito = { items: { "1": 2, "5": 1 }, cupon: "BIENVENIDA10" }
function lineas() {
return Object.entries(carrito.items).map(([id, cant]) => {
const p = PRODUCTOS.find((x) => x.id === Number(id));
return { ...p, cant, subtotal: p.precio * cant };
});
}Persistencia segura
localStorage puede fallar (modo privado, almacenamiento bloqueado) o contener datos viejos. Por eso la carga valida todo y la tienda funciona aunque no se pueda guardar:
js
function cargarCarrito() {
try {
const datos = JSON.parse(localStorage.getItem(CLAVE_STORAGE));
if (datos && typeof datos.items === 'object') {
// Limpia productos que ya no existen y ajusta al stock actual
for (const id of Object.keys(datos.items)) {
const p = PRODUCTOS.find((x) => x.id === Number(id));
if (!p || !(datos.items[id] > 0)) delete datos.items[id];
else datos.items[id] = Math.min(datos.items[id], p.stock);
}
return datos;
}
} catch (e) { /* bloqueado o corrupto */ }
return { items: {}, cupon: null };
}Cambiaste la estructura del carrito
Si en una versión futura cambias el formato guardado, sube la versión de la clave (_v2). Así los clientes empiezan con un carrito limpio en lugar de uno incompatible.
Cálculo de totales
js
function totales() {
const ls = lineas();
const subtotal = ls.reduce((a, l) => a + l.subtotal, 0);
// descuento según el cupón, envío gratis si (subtotal - descuento) >= ENVIO_GRATIS_DESDE
// ...
return { ls, unidades, subtotal, descuento, envio, total };
}Cada acción (agregar, cambiarCantidad, eliminar) modifica el estado, llama a guardarCarrito() y vuelve a pintar con pintarCarrito(). Es el mismo patrón "estado → render" que usan React o Vue, pero en JavaScript puro.
Eventos delegados
En lugar de poner un addEventListener en cada botón (que se recrean al repintar), se escucha en el contenedor y se identifica el botón con atributos data-:
js
$('#items').addEventListener('click', (e) => {
const b = e.target.closest('button');
if (!b) return;
if (b.dataset.mas) cambiarCantidad(Number(b.dataset.mas), 1);
if (b.dataset.menos) cambiarCantidad(Number(b.dataset.menos), -1);
if (b.dataset.eliminar) eliminar(Number(b.dataset.eliminar));
});Personalización visual
css
:root {
--primario: #7c2d12; /* marrón café: logo, botón del carrito, precios */
--acento: #ea580c; /* etiquetas de oferta, contador, barra de envío */
--verde: #15803d; /* "Nuevo", cupón aplicado */
--fondo: #faf7f2;
}Siguientes pasos (cuando el negocio crezca)
| Necesidad | Solución |
|---|---|
| El dueño quiere editar productos sin tocar código | Mueve PRODUCTOS a un productos.json o a una hoja de Google publicada como CSV |
| Pagos con tarjeta | Stripe Payment Links o Mercado Pago Checkout Pro (botón por producto o carrito) |
| Inventario real y pedidos guardados | Backend con el esquema e-commerce y una API en Node o PHP |
| Muchos productos y SEO por producto | WooCommerce, Shopify o una tienda con Next.js |
Los precios del navegador no son confiables
En esta tienda el pedido llega por WhatsApp y el vendedor confirma el total, así que no hay riesgo. Pero si conectas pagos en línea, el servidor debe recalcular el total con los precios de la base de datos: cualquiera puede modificar el JavaScript y comprar a $1.