Ir al contenido
wannacode

El escaparate de la tienda

En resumen: Un proyecto de aplicación web: el escaparate de una tienda con un catálogo desde una API, una página de producto por dirección, un carrito compartido, un pedido con comprobaciones y «Mis pedidos».

Problema

El escaparate de la tienda

La tienda tiene una API: productos, categorías, pedidos. Lo que falta es el escaparate por el que compra el comprador. El camino del comprador es:

  1. Buscar en el catálogo o elegir una categoría.
  2. Abrir un producto y añadirlo al carrito.
  3. Cambiar las cantidades en el carrito.
  4. Pedir: nombre, correo electrónico, dirección. Un error se muestra donde está.
  5. Ver el pedido en «Mis pedidos».

Cada pieza la has construido en una lección propia. Aquí las juntas en una sola aplicación y la construyes en cinco pasos: cada tarea empieza con la solución de la anterior.

La idea

El esqueleto

Esta es la aplicación en el paso cero: el menú y páginas provisionales. Navega por el menú.

Vue

Los archivos están dispuestos como en un proyecto real:

  • api.js contiene todas las peticiones. Las páginas llaman a getProducts, placeOrder y no conocen las direcciones. Un error del servidor llega como un Error con status y errors (los errores por campo).
  • format.js da formato al dinero, las fechas y el estado.
  • pages/ son las páginas, una por dirección. components/ son las piezas que usan las páginas: ProductCard, Filter, Field.
  • store/cart es el carrito, compartido por toda la aplicación.

El router está en router.js con createMemoryHistory(), y main.js pone createPinia() y router en la aplicación. La página va en <RouterView /> en App.vue, como en la lección de enrutamiento.

El sandbox no tiene barra de direcciones, así que el historial se guarda en memoria. En un proyecto real es createWebHistory(). Lo demás es igual.

Filter y Field vienen hechos de las lecciones: campos ligados al estado, y un campo con etiqueta y mensaje de error. Tú escribes las páginas y el carrito.

Ejercicio

Paso 1: el catálogo

El catálogo carga los productos con getProducts({ q, category }) y los muestra con ProductCard en una lista con la clase grid. El filtro está arriba. Cada cambio de la búsqueda o de la categoría inicia una petición nueva, y la antigua se cancela. Mientras espera, la página dice «Cargando…». Cuando nada coincide, dice «No hay productos que coincidan.»

Fíjate en dos cosas de la lección sobre datos de una API:

  • La lista sigue al filtro. La petición está en watch([q, category], …, { immediate: true }), no en onMounted.
  • La respuesta antigua no gana. Si «Lácteos» responde despacio y ya has elegido «Bebidas», la respuesta lenta no debe borrar la nueva. Un AbortController cancela la petición antigua en el onCleanup del watch.

Escribe CatalogPage: los productos de la API según el filtro, «Cargando…», «No hay productos que coincidan.» y la cancelación de la petición antigua. La comprobación busca «pan», luego elige «Lácteos» y enseguida «Bebidas» (Lácteos responde en 900 ms), y después busca dentro de una categoría.

Vue

Ejercicio

Paso 2: la página de producto

El nombre en ProductCard pasa a ser un enlace a /product/:id. ProductPage toma id de la dirección, carga el producto con getProduct(id) y muestra el nombre, la categoría, el precio y las existencias: «En existencias: 18 uds.» o «Agotado». Debajo va «Más de esta categoría»: hasta tres productos más de la misma categoría, con enlaces.

Fíjate en:

  • El enlace es del router: <RouterLink :to="…">, no <a href>. Un enlace normal vuelve a cargar toda la página. En el sandbox no lleva a ninguna parte.
  • La misma página, otro parámetro. De «Queso blanco» a «Queso kashkaval» el router no crea una ProductPage nueva, solo cambia id. La petición está en watch(() => route.params.id, …, { immediate: true }).
  • El id de la dirección es una cadena: "7", no 7. getProduct(id) la pone en la dirección, y eso está bien. Si la buscas en un array con ===, comparas una cadena con un número.

Los detalles están en la lección de enrutamiento.

Haz que el nombre en ProductCard sea un enlace y escribe ProductPage. La comprobación abre «Queso blanco», luego «Queso kashkaval» desde «Más de esta categoría», vuelve al catálogo y abre la «Banitsa de queso» agotada.

Vue

Ejercicio

Paso 3: el carrito

ProductPage y el menú ya llaman a useCart(): un botón «Añadir al carrito», «En el carrito: 2» y «Carrito (2)». Por ahora useCart es un marcador. Escribe el carrito de verdad y CartPage.

store/cart.js es un store de Pinia creado con defineStore('cart', () => { … }), como en la lección sobre el estado compartido. Devuelve items, count, total, add, setQty, remove, clear. count y total son computed a partir de items, no se guardan por separado.

CartPage muestra una fila por producto: el nombre, un menú desplegable «Cantidad: Queso blanco» (de 1 hasta las existencias, como máximo 10), la suma de la fila y un botón «Quitar» con el nombre «Quitar Queso blanco». Debajo van «Total: …» y un enlace «Hacer el pedido». Un carrito vacío dice «El carrito está vacío.»

Escribe store/cart y CartPage. La comprobación añade dos quesos blancos y manzanas, abre el carrito, cambia el queso a 3 y quita las manzanas, fijándose en el menú y en el total.

Vue

Ejercicio

Paso 4: el pedido

CheckoutPage tiene un formulario con tres Field: «Nombre», «Correo electrónico», «Dirección». Con «Confirmar pedido»:

  1. Una comprobación en el navegador. Un nombre vacío: «Escribe tu nombre.» Un correo sin @: «Escribe tu correo electrónico.» Una dirección vacía: «Escribe tu dirección.» Si hay un error, no sale ninguna petición.
  2. La petición. placeOrder({ name, email, address, items }), donde items es [{ productId, qty }] del carrito.
  3. La respuesta. Con un 201 la página dice, por ejemplo, «Pedido n.º 61 por 15,80 €.» y el carrito se vacía. Con un 400, err.errors es un objeto por campo, por ejemplo { email: "El correo electrónico no es válido" }, y cada mensaje va junto a su campo.

Field ya muestra error junto al campo, con aria-invalid y aria-describedby. El formulario lleva novalidate, para que hablen tus mensajes y no los globos del navegador. La comprobación en el navegador es una comodidad: detecta los campos vacíos antes de que salga una petición. El servidor tiene la última palabra, por eso «maria@shop» pasa en el navegador y el servidor lo devuelve. Cómo se construye un formulario está en la lección de formularios.

Escribe CheckoutPage. La comprobación añade dos quesos blancos, pulsa «Confirmar pedido» con el formulario vacío, después con el correo «maria@shop» (el servidor lo rechaza) y por último con un correo válido.

Vue

Ejercicio

Paso 5: mis pedidos y todo el camino

OrdersPage carga getOrders() cada vez que se abre y muestra los pedidos del cliente ME, el más nuevo arriba: «Pedido n.º 61», la fecha, el estado, el total y las líneas («Queso blanco × 3»). Mientras espera, «Cargando…», y sin pedidos, «Todavía no tienes pedidos.»

La API de prácticas no tiene inicio de sesión y devuelve los pedidos de todos los clientes. Por eso la página filtra por customerId === ME. Un servidor real devuelve solo los tuyos.

Ahora la aplicación está completa. La comprobación recorre el camino del principio de la lección: mira los pedidos antiguos, filtra «Lácteos», abre el queso blanco, añade 2, los convierte en 3 en el carrito, pide con un correo equivocado, ve el error junto al campo, lo corrige, pide y encuentra el pedido nuevo arriba en «Mis pedidos».

Escribe OrdersPage. La comprobación recorre todo el camino del comprador y al final busca el pedido nuevo arriba en «Mis pedidos».

Vue

La idea

Qué está simplificado aquí

La aplicación es real, pero algunas cosas alrededor están simplificadas. En una tienda real:

  • El servidor sabe quién eres. Inicias sesión con una contraseña y el servidor devuelve solo tus pedidos, según la sesión. Un filtro en el navegador no protege nada: cualquiera puede cambiar ME.
  • El precio vive en el servidor. El pedido envía solo productId y qty. El total vuelve en la respuesta, calculado con los precios de la base de datos.
  • El carrito sobrevive. Aquí vive en memoria y desaparece al recargar. Uno real está en localStorage o en el servidor.
  • Las direcciones son reales. createWebHistory() en lugar del historial en memoria, y un hosting que devuelve index.html para toda dirección.
Más precisamente, para curiosos

Las dos bibliotecas. El camino es el mismo: direcciones, datos de una API, un carrito compartido, un formulario con errores por campo. Vue lo hace con reactividad: watch sobre el parámetro y el filtro, Pinia para el carrito, v-model y defineModel para los campos. La otra biblioteca resuelve las mismas tareas a su manera.

Tests. Las comprobaciones de los pasos hacen clic y escriben como una persona. Lo mismo lo escribes con Testing Library: render, screen.getByRole, userEvent. Cómo se hace está en la lección de tests.

Velocidad. El catálogo carga con cada pulsación de tecla. Con la petición antigua cancelada esto es correcto, pero derrochador. En un proyecto real la búsqueda espera 300 ms de calma antes de salir (debounce).

La idea

Qué sigue

Lleva el escaparate a un proyecto real, como en la lección «Del sandbox al proyecto»:

Terminal
npm create vite@latest shop -- --template vue
cd shop
npm install vue-router pinia

Copia las carpetas pages/, components/, store/ y los archivos api.js, format.js, router.js, style.css a src/. En router.js cambia createMemoryHistory() por createWebHistory().

La API ya no es simulada. Arranca un servidor real con las direcciones /api/products, /api/orders, o redirige /api a él con server.proxy en vite.config.js. Después npm run build y publica dist/ con una regla que devuelva index.html para toda dirección.

Más tareas

  • Detalles del pedido. /orders/:id con GET /api/orders/:id y un enlace desde «Mis pedidos». Un número desconocido muestra «No existe ese pedido.»
  • Un carrito que sobrevive. Guarda items en localStorage y léelos al arrancar.
  • Tests. Un test para CartPage: cambiar una cantidad cambia el total. Un test para CheckoutPage: un formulario vacío muestra los tres errores y no envía ninguna petición.

Test

Ponte a prueba

1. Eliges «Lácteos» y la lista conserva todos los productos. Los productos se cargan en onMounted. ¿Qué falta?
2. En «Queso blanco» pulsas «Queso kashkaval» en «Más de esta categoría». La dirección pasa a ser /product/8, pero la página sigue mostrando el queso blanco. ¿Por qué?
3. La comprobación del navegador dejó pasar «maria@shop», y el servidor respondió 400 con { errors: { email: "El correo electrónico no es válido" } }. ¿Qué hace la página?
4. useCart es una función que crea un ref([]) en cada llamada, sin Pinia. ProductPage añade, y el menú dice «Carrito (0)». ¿Por qué?
5. ¿Por qué el pedido envía solo productId y qty, sin los precios?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·