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:
- Buscar en el catálogo o elegir una categoría.
- Abrir un producto y añadirlo al carrito.
- Cambiar las cantidades en el carrito.
- Pedir: nombre, correo electrónico, dirección. Un error se muestra donde está.
- 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ú.
Consola
Los archivos están dispuestos como en un proyecto real:
api.jscontiene todas las peticiones. Las páginas llaman agetProducts,placeOrdery no conocen las direcciones. Un error del servidor llega como unErrorconstatusyerrors(los errores por campo).format.jsda 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/cartes 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 enonMounted. - La respuesta antigua no gana. Si «Lácteos» responde despacio y ya has elegido «Bebidas», la respuesta lenta no debe borrar la nueva. Un
AbortControllercancela la petición antigua en elonCleanupdelwatch.
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.
Consola
Solución
main.jsimport { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import { router } from './router.js';
import './style.css';
createApp(App).use(createPinia()).use(router).mount('#app');
router.jsimport { createMemoryHistory, createRouter } from 'vue-router';
import CartPage from './pages/CartPage.vue';
import CatalogPage from './pages/CatalogPage.vue';
import CheckoutPage from './pages/CheckoutPage.vue';
import NotFoundPage from './pages/NotFoundPage.vue';
import OrdersPage from './pages/OrdersPage.vue';
import ProductPage from './pages/ProductPage.vue';
export const router = createRouter({
// El sandbox no tiene barra de direcciones, así que el historial está en memoria.
// En un proyecto real: createWebHistory().
history: createMemoryHistory(),
routes: [
{ path: '/', component: CatalogPage },
{ path: '/product/:id', component: ProductPage },
{ path: '/cart', component: CartPage },
{ path: '/checkout', component: CheckoutPage },
{ path: '/orders', component: OrdersPage },
{ path: '/:pathMatch(.*)*', component: NotFoundPage },
],
});
App.vue<script setup>
import { RouterLink, RouterView } from 'vue-router';
import { useCart } from './store/cart.js';
const cart = useCart();
</script>
<template>
<header>
<strong>La tienda</strong>
<nav aria-label="Menú principal">
<RouterLink to="/">Catálogo</RouterLink>
<RouterLink to="/cart">Carrito ({{ cart.count }})</RouterLink>
<RouterLink to="/orders">Mis pedidos</RouterLink>
</nav>
</header>
<main>
<RouterView />
</main>
</template>
pages/CatalogPage.vue<script setup>
import { onMounted, ref, watch } from 'vue';
import { getCategories, getProducts } from '../api.js';
import Filter from '../components/Filter.vue';
import ProductCard from '../components/ProductCard.vue';
const q = ref('');
const category = ref('');
const categories = ref([]);
const products = ref(null);
const error = ref(null);
onMounted(async () => {
try {
categories.value = await getCategories();
} catch {
categories.value = [];
}
});
watch(
[q, category],
(now, before, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
products.value = null;
error.value = null;
getProducts({ q: q.value, category: category.value }, controller.signal)
.then((list) => (products.value = list))
.catch((e) => {
if (e.name !== 'AbortError') error.value = e.message;
});
},
{ immediate: true },
);
</script>
<template>
<h1>Catálogo</h1>
<Filter v-model:q="q" v-model:category="category" :categories="categories" />
<p v-if="error" role="alert">{{ error }}</p>
<p v-else-if="!products" role="status">Cargando…</p>
<p v-else-if="products.length === 0">No hay productos que coincidan.</p>
<ul v-else class="grid">
<ProductCard v-for="p in products" :key="p.id" :product="p" />
</ul>
</template>
pages/ProductPage.vue<template>
<h1>Producto</h1>
<p>Aquí va el producto.</p>
</template>
pages/CartPage.vue<template>
<h1>Carrito</h1>
<p>Aquí va el carrito.</p>
</template>
pages/CheckoutPage.vue<template>
<h1>Datos del pedido</h1>
<p>Aquí va el pedido.</p>
</template>
pages/OrdersPage.vue<template>
<h1>Mis pedidos</h1>
<p>Aquí van tus pedidos.</p>
</template>
pages/NotFoundPage.vue<script setup>
import { RouterLink } from 'vue-router';
</script>
<template>
<h1>Esa página no existe</h1>
<p>
<RouterLink to="/">Volver al catálogo</RouterLink>
</p>
</template>
components/ProductCard.vue<script setup>
import { money } from '../format.js';
defineProps(['product']);
</script>
<template>
<li class="card">
<h2>{{ product.name }}</h2>
<p v-if="product.category" class="muted">{{ product.category }}</p>
<p class="price">{{ money(product.price) }}</p>
</li>
</template>
components/Filter.vue<script setup>
import { computed } from 'vue';
const props = defineProps(['categories']);
const q = defineModel('q');
const category = defineModel('category');
// Los productos están en las subcategorías: «Alimentos» y «Hogar» son solo grupos.
const groups = computed(() => props.categories.filter((c) => c.parentId !== null));
</script>
<template>
<form class="filter" role="search" @submit.prevent>
<label>
Búsqueda
<input v-model="q" type="search" />
</label>
<label>
Categoría
<select v-model="category">
<option value="">Todas</option>
<option v-for="c in groups" :key="c.id" :value="String(c.id)">{{ c.name }}</option>
</select>
</label>
</form>
</template>
components/Field.vue<script setup>
const value = defineModel();
defineProps({
name: String,
label: String,
type: { type: String, default: 'text' },
error: String,
});
</script>
<template>
<div class="field">
<label :for="name">{{ label }}</label>
<input
:id="name"
v-model="value"
:name="name"
:type="type"
:aria-invalid="error ? 'true' : undefined"
:aria-describedby="error ? `${name}-error` : undefined"
/>
<span v-if="error" :id="`${name}-error`" class="error">{{ error }}</span>
</div>
</template>
store/cart.js// El carrito llega en el paso 3. Hasta entonces está vacío y no cambia.
export function useCart() {
return { items: [], count: 0, total: 0, add() {}, setQty() {}, remove() {}, clear() {} };
}
api.js// Todas las peticiones a la API de la tienda están aquí: las páginas no conocen las direcciones.
async function request(path, options = {}) {
const res = await fetch(path, options);
const data = await res.json();
if (!res.ok) {
const error = new Error(data.error ?? `El servidor respondió ${res.status}`);
error.status = res.status;
error.errors = data.errors;
throw error;
}
return data;
}
export function getCategories() {
return request('/api/categories');
}
export function getProducts({ q = '', category = '' } = {}, signal) {
const params = new URLSearchParams();
if (q) params.set('q', q);
if (category) params.set('category', category);
return request(`/api/products?${params}`, { signal });
}
export function getProduct(id, signal) {
return request(`/api/products/${id}`, { signal });
}
export function placeOrder(order) {
return request('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(order),
});
}
// La API de prácticas no tiene inicio de sesión y devuelve los pedidos de todos los clientes. Tú eres el cliente 1.
// Un servidor real devuelve solo los tuyos: sabe quién eres por la sesión.
export const ME = 1;
export function getOrders(signal) {
return request('/api/orders', { signal });
}
format.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
export const date = (iso) => new Date(iso).toLocaleDateString('es-ES');
const STATUS = { new: 'Nuevo', paid: 'Pagado', shipped: 'Enviado', delivered: 'Entregado' };
export const statusText = (status) => STATUS[status] ?? status;
style.css* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, sans-serif; color: #1f2328; background: #fbfaf7; }
header { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 16px; padding: 8px 16px; background: #f3efe6; }
header strong { margin-right: auto; }
nav { display: flex; flex-wrap: wrap; gap: 12px; }
nav a { color: inherit; }
nav a[aria-current="page"] { font-weight: 600; text-decoration-thickness: 2px; }
main { padding: 0 16px 24px; }
h1 { font-size: 1.4rem; }
input, select, button { font: inherit; padding: 4px 8px; }
.filter { display: flex; flex-wrap: wrap; gap: 8px 16px; margin-bottom: 12px; }
.filter label { display: flex; flex-direction: column; gap: 2px; font-size: 0.9rem; }
.grid { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 8px; }
.card { background: #fff; border: 1px solid #ddd6c8; border-radius: 8px; padding: 8px 10px; }
.card h2 { font-size: 1rem; margin: 0 0 4px; }
.card p { margin: 0; }
.muted { color: #5c6168; font-size: 0.85rem; }
.price { font-weight: 600; }
.cart, .orders { list-style: none; padding: 0; }
.cart li { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid #ddd6c8; }
.cart li span:first-child { flex: 1; }
.orders li { padding: 6px 0; border-bottom: 1px solid #ddd6c8; }
.orders h2 { font-size: 1rem; margin: 0; }
.orders p { margin: 2px 0; }
.field { display: flex; flex-direction: column; gap: 2px; max-width: 320px; margin-bottom: 10px; }
.field input[aria-invalid="true"] { border-color: #b42318; }
.error { color: #b42318; font-size: 0.85rem; }
La comprobación detecta tres errores: una petición solo una vez, cuando aparece la página; una búsqueda que no está en las watch; y una petición sin cancelar, en la que la respuesta de «Lácteos» llega la última y muestra queso mientras el campo dice «Bebidas».
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
ProductPagenueva, solo cambiaid. La petición está enwatch(() => route.params.id, …, { immediate: true }). - El
idde la dirección es una cadena:"7", no7.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.
Consola
Solución
main.jsimport { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import { router } from './router.js';
import './style.css';
createApp(App).use(createPinia()).use(router).mount('#app');
router.jsimport { createMemoryHistory, createRouter } from 'vue-router';
import CartPage from './pages/CartPage.vue';
import CatalogPage from './pages/CatalogPage.vue';
import CheckoutPage from './pages/CheckoutPage.vue';
import NotFoundPage from './pages/NotFoundPage.vue';
import OrdersPage from './pages/OrdersPage.vue';
import ProductPage from './pages/ProductPage.vue';
export const router = createRouter({
// El sandbox no tiene barra de direcciones, así que el historial está en memoria.
// En un proyecto real: createWebHistory().
history: createMemoryHistory(),
routes: [
{ path: '/', component: CatalogPage },
{ path: '/product/:id', component: ProductPage },
{ path: '/cart', component: CartPage },
{ path: '/checkout', component: CheckoutPage },
{ path: '/orders', component: OrdersPage },
{ path: '/:pathMatch(.*)*', component: NotFoundPage },
],
});
App.vue<script setup>
import { RouterLink, RouterView } from 'vue-router';
import { useCart } from './store/cart.js';
const cart = useCart();
</script>
<template>
<header>
<strong>La tienda</strong>
<nav aria-label="Menú principal">
<RouterLink to="/">Catálogo</RouterLink>
<RouterLink to="/cart">Carrito ({{ cart.count }})</RouterLink>
<RouterLink to="/orders">Mis pedidos</RouterLink>
</nav>
</header>
<main>
<RouterView />
</main>
</template>
pages/CatalogPage.vue<script setup>
import { onMounted, ref, watch } from 'vue';
import { getCategories, getProducts } from '../api.js';
import Filter from '../components/Filter.vue';
import ProductCard from '../components/ProductCard.vue';
const q = ref('');
const category = ref('');
const categories = ref([]);
const products = ref(null);
const error = ref(null);
onMounted(async () => {
try {
categories.value = await getCategories();
} catch {
categories.value = [];
}
});
watch(
[q, category],
(now, before, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
products.value = null;
error.value = null;
getProducts({ q: q.value, category: category.value }, controller.signal)
.then((list) => (products.value = list))
.catch((e) => {
if (e.name !== 'AbortError') error.value = e.message;
});
},
{ immediate: true },
);
</script>
<template>
<h1>Catálogo</h1>
<Filter v-model:q="q" v-model:category="category" :categories="categories" />
<p v-if="error" role="alert">{{ error }}</p>
<p v-else-if="!products" role="status">Cargando…</p>
<p v-else-if="products.length === 0">No hay productos que coincidan.</p>
<ul v-else class="grid">
<ProductCard v-for="p in products" :key="p.id" :product="p" />
</ul>
</template>
pages/ProductPage.vue<script setup>
import { watch, ref } from 'vue';
import { RouterLink, useRoute } from 'vue-router';
import { getProduct, getProducts } from '../api.js';
import { money } from '../format.js';
const route = useRoute();
const product = ref(null);
const related = ref([]);
const error = ref(null);
async function load(id, signal) {
const p = await getProduct(id, signal);
product.value = p;
if (p.categoryId === null) return;
const same = await getProducts({ category: p.categoryId }, signal);
related.value = same.filter((x) => x.id !== p.id).slice(0, 3);
}
watch(
() => route.params.id,
(id, oldId, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
product.value = null;
related.value = [];
error.value = null;
load(id, controller.signal).catch((e) => {
if (e.name !== 'AbortError') error.value = e.status === 404 ? 'No existe ese producto.' : e.message;
});
},
{ immediate: true },
);
</script>
<template>
<p v-if="error" role="alert">{{ error }}</p>
<p v-else-if="!product" role="status">Cargando…</p>
<article v-else>
<p>
<RouterLink to="/">← Volver al catálogo</RouterLink>
</p>
<h1>{{ product.name }}</h1>
<p v-if="product.category" class="muted">{{ product.category }}</p>
<p class="price">{{ money(product.price) }}</p>
<p v-if="product.stock > 0">En existencias: {{ product.stock }} uds.</p>
<p v-else>Agotado</p>
<section v-if="related.length > 0" aria-labelledby="related-title">
<h2 id="related-title">Más de esta categoría</h2>
<ul>
<li v-for="p in related" :key="p.id">
<RouterLink :to="`/product/${p.id}`">{{ p.name }}</RouterLink>
</li>
</ul>
</section>
</article>
</template>
pages/CartPage.vue<template>
<h1>Carrito</h1>
<p>Aquí va el carrito.</p>
</template>
pages/CheckoutPage.vue<template>
<h1>Datos del pedido</h1>
<p>Aquí va el pedido.</p>
</template>
pages/OrdersPage.vue<template>
<h1>Mis pedidos</h1>
<p>Aquí van tus pedidos.</p>
</template>
pages/NotFoundPage.vue<script setup>
import { RouterLink } from 'vue-router';
</script>
<template>
<h1>Esa página no existe</h1>
<p>
<RouterLink to="/">Volver al catálogo</RouterLink>
</p>
</template>
components/ProductCard.vue<script setup>
import { RouterLink } from 'vue-router';
import { money } from '../format.js';
defineProps(['product']);
</script>
<template>
<li class="card">
<h2>
<RouterLink :to="`/product/${product.id}`">{{ product.name }}</RouterLink>
</h2>
<p v-if="product.category" class="muted">{{ product.category }}</p>
<p class="price">{{ money(product.price) }}</p>
</li>
</template>
components/Filter.vue<script setup>
import { computed } from 'vue';
const props = defineProps(['categories']);
const q = defineModel('q');
const category = defineModel('category');
// Los productos están en las subcategorías: «Alimentos» y «Hogar» son solo grupos.
const groups = computed(() => props.categories.filter((c) => c.parentId !== null));
</script>
<template>
<form class="filter" role="search" @submit.prevent>
<label>
Búsqueda
<input v-model="q" type="search" />
</label>
<label>
Categoría
<select v-model="category">
<option value="">Todas</option>
<option v-for="c in groups" :key="c.id" :value="String(c.id)">{{ c.name }}</option>
</select>
</label>
</form>
</template>
components/Field.vue<script setup>
const value = defineModel();
defineProps({
name: String,
label: String,
type: { type: String, default: 'text' },
error: String,
});
</script>
<template>
<div class="field">
<label :for="name">{{ label }}</label>
<input
:id="name"
v-model="value"
:name="name"
:type="type"
:aria-invalid="error ? 'true' : undefined"
:aria-describedby="error ? `${name}-error` : undefined"
/>
<span v-if="error" :id="`${name}-error`" class="error">{{ error }}</span>
</div>
</template>
store/cart.js// El carrito llega en el paso 3. Hasta entonces está vacío y no cambia.
export function useCart() {
return { items: [], count: 0, total: 0, add() {}, setQty() {}, remove() {}, clear() {} };
}
api.js// Todas las peticiones a la API de la tienda están aquí: las páginas no conocen las direcciones.
async function request(path, options = {}) {
const res = await fetch(path, options);
const data = await res.json();
if (!res.ok) {
const error = new Error(data.error ?? `El servidor respondió ${res.status}`);
error.status = res.status;
error.errors = data.errors;
throw error;
}
return data;
}
export function getCategories() {
return request('/api/categories');
}
export function getProducts({ q = '', category = '' } = {}, signal) {
const params = new URLSearchParams();
if (q) params.set('q', q);
if (category) params.set('category', category);
return request(`/api/products?${params}`, { signal });
}
export function getProduct(id, signal) {
return request(`/api/products/${id}`, { signal });
}
export function placeOrder(order) {
return request('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(order),
});
}
// La API de prácticas no tiene inicio de sesión y devuelve los pedidos de todos los clientes. Tú eres el cliente 1.
// Un servidor real devuelve solo los tuyos: sabe quién eres por la sesión.
export const ME = 1;
export function getOrders(signal) {
return request('/api/orders', { signal });
}
format.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
export const date = (iso) => new Date(iso).toLocaleDateString('es-ES');
const STATUS = { new: 'Nuevo', paid: 'Pagado', shipped: 'Enviado', delivered: 'Entregado' };
export const statusText = (status) => STATUS[status] ?? status;
style.css* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, sans-serif; color: #1f2328; background: #fbfaf7; }
header { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 16px; padding: 8px 16px; background: #f3efe6; }
header strong { margin-right: auto; }
nav { display: flex; flex-wrap: wrap; gap: 12px; }
nav a { color: inherit; }
nav a[aria-current="page"] { font-weight: 600; text-decoration-thickness: 2px; }
main { padding: 0 16px 24px; }
h1 { font-size: 1.4rem; }
input, select, button { font: inherit; padding: 4px 8px; }
.filter { display: flex; flex-wrap: wrap; gap: 8px 16px; margin-bottom: 12px; }
.filter label { display: flex; flex-direction: column; gap: 2px; font-size: 0.9rem; }
.grid { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 8px; }
.card { background: #fff; border: 1px solid #ddd6c8; border-radius: 8px; padding: 8px 10px; }
.card h2 { font-size: 1rem; margin: 0 0 4px; }
.card p { margin: 0; }
.muted { color: #5c6168; font-size: 0.85rem; }
.price { font-weight: 600; }
.cart, .orders { list-style: none; padding: 0; }
.cart li { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid #ddd6c8; }
.cart li span:first-child { flex: 1; }
.orders li { padding: 6px 0; border-bottom: 1px solid #ddd6c8; }
.orders h2 { font-size: 1rem; margin: 0; }
.orders p { margin: 2px 0; }
.field { display: flex; flex-direction: column; gap: 2px; max-width: 320px; margin-bottom: 10px; }
.field input[aria-invalid="true"] { border-color: #b42318; }
.error { color: #b42318; font-size: 0.85rem; }
La comprobación detecta <a href> (la consola dice «El enlace lleva a /product/7»), una página que carga el producto solo la primera vez que aparece (se queda en el queso blanco), y find((x) => x.id === id), que no encuentra nada porque "7" !== 7.
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.
Consola
Solución
main.jsimport { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import { router } from './router.js';
import './style.css';
createApp(App).use(createPinia()).use(router).mount('#app');
router.jsimport { createMemoryHistory, createRouter } from 'vue-router';
import CartPage from './pages/CartPage.vue';
import CatalogPage from './pages/CatalogPage.vue';
import CheckoutPage from './pages/CheckoutPage.vue';
import NotFoundPage from './pages/NotFoundPage.vue';
import OrdersPage from './pages/OrdersPage.vue';
import ProductPage from './pages/ProductPage.vue';
export const router = createRouter({
// El sandbox no tiene barra de direcciones, así que el historial está en memoria.
// En un proyecto real: createWebHistory().
history: createMemoryHistory(),
routes: [
{ path: '/', component: CatalogPage },
{ path: '/product/:id', component: ProductPage },
{ path: '/cart', component: CartPage },
{ path: '/checkout', component: CheckoutPage },
{ path: '/orders', component: OrdersPage },
{ path: '/:pathMatch(.*)*', component: NotFoundPage },
],
});
App.vue<script setup>
import { RouterLink, RouterView } from 'vue-router';
import { useCart } from './store/cart.js';
const cart = useCart();
</script>
<template>
<header>
<strong>La tienda</strong>
<nav aria-label="Menú principal">
<RouterLink to="/">Catálogo</RouterLink>
<RouterLink to="/cart">Carrito ({{ cart.count }})</RouterLink>
<RouterLink to="/orders">Mis pedidos</RouterLink>
</nav>
</header>
<main>
<RouterView />
</main>
</template>
pages/CatalogPage.vue<script setup>
import { onMounted, ref, watch } from 'vue';
import { getCategories, getProducts } from '../api.js';
import Filter from '../components/Filter.vue';
import ProductCard from '../components/ProductCard.vue';
const q = ref('');
const category = ref('');
const categories = ref([]);
const products = ref(null);
const error = ref(null);
onMounted(async () => {
try {
categories.value = await getCategories();
} catch {
categories.value = [];
}
});
watch(
[q, category],
(now, before, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
products.value = null;
error.value = null;
getProducts({ q: q.value, category: category.value }, controller.signal)
.then((list) => (products.value = list))
.catch((e) => {
if (e.name !== 'AbortError') error.value = e.message;
});
},
{ immediate: true },
);
</script>
<template>
<h1>Catálogo</h1>
<Filter v-model:q="q" v-model:category="category" :categories="categories" />
<p v-if="error" role="alert">{{ error }}</p>
<p v-else-if="!products" role="status">Cargando…</p>
<p v-else-if="products.length === 0">No hay productos que coincidan.</p>
<ul v-else class="grid">
<ProductCard v-for="p in products" :key="p.id" :product="p" />
</ul>
</template>
pages/ProductPage.vue<script setup>
import { computed, watch, ref } from 'vue';
import { RouterLink, useRoute } from 'vue-router';
import { getProduct, getProducts } from '../api.js';
import { money } from '../format.js';
import { useCart } from '../store/cart.js';
const route = useRoute();
const cart = useCart();
const product = ref(null);
const related = ref([]);
const error = ref(null);
const inCart = computed(() => cart.items.find((i) => i.id === product.value?.id)?.qty ?? 0);
async function load(id, signal) {
const p = await getProduct(id, signal);
product.value = p;
if (p.categoryId === null) return;
const same = await getProducts({ category: p.categoryId }, signal);
related.value = same.filter((x) => x.id !== p.id).slice(0, 3);
}
watch(
() => route.params.id,
(id, oldId, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
product.value = null;
related.value = [];
error.value = null;
load(id, controller.signal).catch((e) => {
if (e.name !== 'AbortError') error.value = e.status === 404 ? 'No existe ese producto.' : e.message;
});
},
{ immediate: true },
);
</script>
<template>
<p v-if="error" role="alert">{{ error }}</p>
<p v-else-if="!product" role="status">Cargando…</p>
<article v-else>
<p>
<RouterLink to="/">← Volver al catálogo</RouterLink>
</p>
<h1>{{ product.name }}</h1>
<p v-if="product.category" class="muted">{{ product.category }}</p>
<p class="price">{{ money(product.price) }}</p>
<p v-if="product.stock > 0">En existencias: {{ product.stock }} uds.</p>
<p v-else>Agotado</p>
<button type="button" :disabled="product.stock === 0" @click="cart.add(product)">
Añadir al carrito
</button>
<p v-if="inCart > 0" role="status">En el carrito: {{ inCart }}</p>
<section v-if="related.length > 0" aria-labelledby="related-title">
<h2 id="related-title">Más de esta categoría</h2>
<ul>
<li v-for="p in related" :key="p.id">
<RouterLink :to="`/product/${p.id}`">{{ p.name }}</RouterLink>
</li>
</ul>
</section>
</article>
</template>
pages/CartPage.vue<script setup>
import { RouterLink } from 'vue-router';
import { money } from '../format.js';
import { useCart } from '../store/cart.js';
const cart = useCart();
const upTo = (n) => Array.from({ length: Math.min(n, 10) }, (_, i) => i + 1);
</script>
<template>
<h1>Carrito</h1>
<template v-if="cart.items.length === 0">
<p>El carrito está vacío.</p>
<p>
<RouterLink to="/">Volver al catálogo</RouterLink>
</p>
</template>
<template v-else>
<ul class="cart">
<li v-for="item in cart.items" :key="item.id">
<span>{{ item.name }}</span>
<select
:aria-label="`Cantidad: ${item.name}`"
:value="item.qty"
@change="cart.setQty(item.id, Number($event.target.value))"
>
<option v-for="n in upTo(item.stock)" :key="n">{{ n }}</option>
</select>
<span>{{ money(item.price * item.qty) }}</span>
<button type="button" :aria-label="`Quitar ${item.name}`" @click="cart.remove(item.id)">
Quitar
</button>
</li>
</ul>
<p class="price">Total: {{ money(cart.total) }}</p>
<RouterLink to="/checkout">Hacer el pedido</RouterLink>
</template>
</template>
pages/CheckoutPage.vue<template>
<h1>Datos del pedido</h1>
<p>Aquí va el pedido.</p>
</template>
pages/OrdersPage.vue<template>
<h1>Mis pedidos</h1>
<p>Aquí van tus pedidos.</p>
</template>
pages/NotFoundPage.vue<script setup>
import { RouterLink } from 'vue-router';
</script>
<template>
<h1>Esa página no existe</h1>
<p>
<RouterLink to="/">Volver al catálogo</RouterLink>
</p>
</template>
components/ProductCard.vue<script setup>
import { RouterLink } from 'vue-router';
import { money } from '../format.js';
defineProps(['product']);
</script>
<template>
<li class="card">
<h2>
<RouterLink :to="`/product/${product.id}`">{{ product.name }}</RouterLink>
</h2>
<p v-if="product.category" class="muted">{{ product.category }}</p>
<p class="price">{{ money(product.price) }}</p>
</li>
</template>
components/Filter.vue<script setup>
import { computed } from 'vue';
const props = defineProps(['categories']);
const q = defineModel('q');
const category = defineModel('category');
// Los productos están en las subcategorías: «Alimentos» y «Hogar» son solo grupos.
const groups = computed(() => props.categories.filter((c) => c.parentId !== null));
</script>
<template>
<form class="filter" role="search" @submit.prevent>
<label>
Búsqueda
<input v-model="q" type="search" />
</label>
<label>
Categoría
<select v-model="category">
<option value="">Todas</option>
<option v-for="c in groups" :key="c.id" :value="String(c.id)">{{ c.name }}</option>
</select>
</label>
</form>
</template>
components/Field.vue<script setup>
const value = defineModel();
defineProps({
name: String,
label: String,
type: { type: String, default: 'text' },
error: String,
});
</script>
<template>
<div class="field">
<label :for="name">{{ label }}</label>
<input
:id="name"
v-model="value"
:name="name"
:type="type"
:aria-invalid="error ? 'true' : undefined"
:aria-describedby="error ? `${name}-error` : undefined"
/>
<span v-if="error" :id="`${name}-error`" class="error">{{ error }}</span>
</div>
</template>
store/cart.jsimport { computed, ref } from 'vue';
import { defineStore } from 'pinia';
export const useCart = defineStore('cart', () => {
const items = ref([]);
const count = computed(() => items.value.reduce((sum, i) => sum + i.qty, 0));
const total = computed(() => items.value.reduce((sum, i) => sum + i.price * i.qty, 0));
function add(product) {
const found = items.value.find((i) => i.id === product.id);
if (found) found.qty += 1;
else items.value.push({ id: product.id, name: product.name, price: product.price, stock: product.stock, qty: 1 });
}
function setQty(id, qty) {
const found = items.value.find((i) => i.id === id);
if (found) found.qty = qty;
}
function remove(id) {
items.value = items.value.filter((i) => i.id !== id);
}
function clear() {
items.value = [];
}
return { items, count, total, add, setQty, remove, clear };
});
api.js// Todas las peticiones a la API de la tienda están aquí: las páginas no conocen las direcciones.
async function request(path, options = {}) {
const res = await fetch(path, options);
const data = await res.json();
if (!res.ok) {
const error = new Error(data.error ?? `El servidor respondió ${res.status}`);
error.status = res.status;
error.errors = data.errors;
throw error;
}
return data;
}
export function getCategories() {
return request('/api/categories');
}
export function getProducts({ q = '', category = '' } = {}, signal) {
const params = new URLSearchParams();
if (q) params.set('q', q);
if (category) params.set('category', category);
return request(`/api/products?${params}`, { signal });
}
export function getProduct(id, signal) {
return request(`/api/products/${id}`, { signal });
}
export function placeOrder(order) {
return request('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(order),
});
}
// La API de prácticas no tiene inicio de sesión y devuelve los pedidos de todos los clientes. Tú eres el cliente 1.
// Un servidor real devuelve solo los tuyos: sabe quién eres por la sesión.
export const ME = 1;
export function getOrders(signal) {
return request('/api/orders', { signal });
}
format.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
export const date = (iso) => new Date(iso).toLocaleDateString('es-ES');
const STATUS = { new: 'Nuevo', paid: 'Pagado', shipped: 'Enviado', delivered: 'Entregado' };
export const statusText = (status) => STATUS[status] ?? status;
style.css* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, sans-serif; color: #1f2328; background: #fbfaf7; }
header { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 16px; padding: 8px 16px; background: #f3efe6; }
header strong { margin-right: auto; }
nav { display: flex; flex-wrap: wrap; gap: 12px; }
nav a { color: inherit; }
nav a[aria-current="page"] { font-weight: 600; text-decoration-thickness: 2px; }
main { padding: 0 16px 24px; }
h1 { font-size: 1.4rem; }
input, select, button { font: inherit; padding: 4px 8px; }
.filter { display: flex; flex-wrap: wrap; gap: 8px 16px; margin-bottom: 12px; }
.filter label { display: flex; flex-direction: column; gap: 2px; font-size: 0.9rem; }
.grid { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 8px; }
.card { background: #fff; border: 1px solid #ddd6c8; border-radius: 8px; padding: 8px 10px; }
.card h2 { font-size: 1rem; margin: 0 0 4px; }
.card p { margin: 0; }
.muted { color: #5c6168; font-size: 0.85rem; }
.price { font-weight: 600; }
.cart, .orders { list-style: none; padding: 0; }
.cart li { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid #ddd6c8; }
.cart li span:first-child { flex: 1; }
.orders li { padding: 6px 0; border-bottom: 1px solid #ddd6c8; }
.orders h2 { font-size: 1rem; margin: 0; }
.orders p { margin: 2px 0; }
.field { display: flex; flex-direction: column; gap: 2px; max-width: 320px; margin-bottom: 10px; }
.field input[aria-invalid="true"] { border-color: #b42318; }
.error { color: #b42318; font-size: 0.85rem; }
useCart sin Pinia, como una función con ref([]) (cada componente tiene su propio carrito y el menú dice 0), y un total en un ref aparte que cambiar una cantidad no actualiza.Ejercicio
Paso 4: el pedido
CheckoutPage tiene un formulario con tres Field: «Nombre», «Correo electrónico», «Dirección». Con «Confirmar pedido»:
- 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. - La petición.
placeOrder({ name, email, address, items }), dondeitemses[{ productId, qty }]del carrito. - 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.errorses 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.
Consola
Solución
main.jsimport { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import { router } from './router.js';
import './style.css';
createApp(App).use(createPinia()).use(router).mount('#app');
router.jsimport { createMemoryHistory, createRouter } from 'vue-router';
import CartPage from './pages/CartPage.vue';
import CatalogPage from './pages/CatalogPage.vue';
import CheckoutPage from './pages/CheckoutPage.vue';
import NotFoundPage from './pages/NotFoundPage.vue';
import OrdersPage from './pages/OrdersPage.vue';
import ProductPage from './pages/ProductPage.vue';
export const router = createRouter({
// El sandbox no tiene barra de direcciones, así que el historial está en memoria.
// En un proyecto real: createWebHistory().
history: createMemoryHistory(),
routes: [
{ path: '/', component: CatalogPage },
{ path: '/product/:id', component: ProductPage },
{ path: '/cart', component: CartPage },
{ path: '/checkout', component: CheckoutPage },
{ path: '/orders', component: OrdersPage },
{ path: '/:pathMatch(.*)*', component: NotFoundPage },
],
});
App.vue<script setup>
import { RouterLink, RouterView } from 'vue-router';
import { useCart } from './store/cart.js';
const cart = useCart();
</script>
<template>
<header>
<strong>La tienda</strong>
<nav aria-label="Menú principal">
<RouterLink to="/">Catálogo</RouterLink>
<RouterLink to="/cart">Carrito ({{ cart.count }})</RouterLink>
<RouterLink to="/orders">Mis pedidos</RouterLink>
</nav>
</header>
<main>
<RouterView />
</main>
</template>
pages/CatalogPage.vue<script setup>
import { onMounted, ref, watch } from 'vue';
import { getCategories, getProducts } from '../api.js';
import Filter from '../components/Filter.vue';
import ProductCard from '../components/ProductCard.vue';
const q = ref('');
const category = ref('');
const categories = ref([]);
const products = ref(null);
const error = ref(null);
onMounted(async () => {
try {
categories.value = await getCategories();
} catch {
categories.value = [];
}
});
watch(
[q, category],
(now, before, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
products.value = null;
error.value = null;
getProducts({ q: q.value, category: category.value }, controller.signal)
.then((list) => (products.value = list))
.catch((e) => {
if (e.name !== 'AbortError') error.value = e.message;
});
},
{ immediate: true },
);
</script>
<template>
<h1>Catálogo</h1>
<Filter v-model:q="q" v-model:category="category" :categories="categories" />
<p v-if="error" role="alert">{{ error }}</p>
<p v-else-if="!products" role="status">Cargando…</p>
<p v-else-if="products.length === 0">No hay productos que coincidan.</p>
<ul v-else class="grid">
<ProductCard v-for="p in products" :key="p.id" :product="p" />
</ul>
</template>
pages/ProductPage.vue<script setup>
import { computed, watch, ref } from 'vue';
import { RouterLink, useRoute } from 'vue-router';
import { getProduct, getProducts } from '../api.js';
import { money } from '../format.js';
import { useCart } from '../store/cart.js';
const route = useRoute();
const cart = useCart();
const product = ref(null);
const related = ref([]);
const error = ref(null);
const inCart = computed(() => cart.items.find((i) => i.id === product.value?.id)?.qty ?? 0);
async function load(id, signal) {
const p = await getProduct(id, signal);
product.value = p;
if (p.categoryId === null) return;
const same = await getProducts({ category: p.categoryId }, signal);
related.value = same.filter((x) => x.id !== p.id).slice(0, 3);
}
watch(
() => route.params.id,
(id, oldId, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
product.value = null;
related.value = [];
error.value = null;
load(id, controller.signal).catch((e) => {
if (e.name !== 'AbortError') error.value = e.status === 404 ? 'No existe ese producto.' : e.message;
});
},
{ immediate: true },
);
</script>
<template>
<p v-if="error" role="alert">{{ error }}</p>
<p v-else-if="!product" role="status">Cargando…</p>
<article v-else>
<p>
<RouterLink to="/">← Volver al catálogo</RouterLink>
</p>
<h1>{{ product.name }}</h1>
<p v-if="product.category" class="muted">{{ product.category }}</p>
<p class="price">{{ money(product.price) }}</p>
<p v-if="product.stock > 0">En existencias: {{ product.stock }} uds.</p>
<p v-else>Agotado</p>
<button type="button" :disabled="product.stock === 0" @click="cart.add(product)">
Añadir al carrito
</button>
<p v-if="inCart > 0" role="status">En el carrito: {{ inCart }}</p>
<section v-if="related.length > 0" aria-labelledby="related-title">
<h2 id="related-title">Más de esta categoría</h2>
<ul>
<li v-for="p in related" :key="p.id">
<RouterLink :to="`/product/${p.id}`">{{ p.name }}</RouterLink>
</li>
</ul>
</section>
</article>
</template>
pages/CartPage.vue<script setup>
import { RouterLink } from 'vue-router';
import { money } from '../format.js';
import { useCart } from '../store/cart.js';
const cart = useCart();
const upTo = (n) => Array.from({ length: Math.min(n, 10) }, (_, i) => i + 1);
</script>
<template>
<h1>Carrito</h1>
<template v-if="cart.items.length === 0">
<p>El carrito está vacío.</p>
<p>
<RouterLink to="/">Volver al catálogo</RouterLink>
</p>
</template>
<template v-else>
<ul class="cart">
<li v-for="item in cart.items" :key="item.id">
<span>{{ item.name }}</span>
<select
:aria-label="`Cantidad: ${item.name}`"
:value="item.qty"
@change="cart.setQty(item.id, Number($event.target.value))"
>
<option v-for="n in upTo(item.stock)" :key="n">{{ n }}</option>
</select>
<span>{{ money(item.price * item.qty) }}</span>
<button type="button" :aria-label="`Quitar ${item.name}`" @click="cart.remove(item.id)">
Quitar
</button>
</li>
</ul>
<p class="price">Total: {{ money(cart.total) }}</p>
<RouterLink to="/checkout">Hacer el pedido</RouterLink>
</template>
</template>
pages/CheckoutPage.vue<script setup>
import { reactive, ref } from 'vue';
import { RouterLink } from 'vue-router';
import { placeOrder } from '../api.js';
import Field from '../components/Field.vue';
import { money } from '../format.js';
import { useCart } from '../store/cart.js';
const cart = useCart();
const form = reactive({ name: '', email: '', address: '' });
const errors = ref({});
const sending = ref(false);
const order = ref(null);
function check() {
const found = {};
if (!form.name.trim()) found.name = 'Escribe tu nombre.';
if (!form.email.includes('@')) found.email = 'Escribe tu correo electrónico.';
if (!form.address.trim()) found.address = 'Escribe tu dirección.';
return found;
}
async function submit() {
errors.value = check();
if (Object.keys(errors.value).length > 0) return;
sending.value = true;
try {
const items = cart.items.map((i) => ({ productId: i.id, qty: i.qty }));
order.value = await placeOrder({ ...form, items });
cart.clear();
} catch (err) {
errors.value = err.errors ?? { form: err.message };
} finally {
sending.value = false;
}
}
</script>
<template>
<template v-if="order">
<h1>Pedido recibido</h1>
<p role="status">Pedido n.º {{ order.id }} por {{ money(order.total) }}.</p>
<p>
<RouterLink to="/orders">Mis pedidos</RouterLink>
</p>
</template>
<template v-else-if="cart.items.length === 0">
<h1>Datos del pedido</h1>
<p>El carrito está vacío.</p>
</template>
<template v-else>
<h1>Datos del pedido</h1>
<p class="price">Total: {{ money(cart.total) }}</p>
<form novalidate @submit.prevent="submit">
<Field v-model="form.name" name="name" label="Nombre" :error="errors.name" />
<Field v-model="form.email" name="email" label="Correo electrónico" type="email" :error="errors.email" />
<Field v-model="form.address" name="address" label="Dirección" :error="errors.address" />
<p v-if="errors.items" role="alert">{{ errors.items }}</p>
<p v-if="errors.form" role="alert">{{ errors.form }}</p>
<button :disabled="sending">{{ sending ? 'Enviando…' : 'Confirmar pedido' }}</button>
</form>
</template>
</template>
pages/OrdersPage.vue<template>
<h1>Mis pedidos</h1>
<p>Aquí van tus pedidos.</p>
</template>
pages/NotFoundPage.vue<script setup>
import { RouterLink } from 'vue-router';
</script>
<template>
<h1>Esa página no existe</h1>
<p>
<RouterLink to="/">Volver al catálogo</RouterLink>
</p>
</template>
components/ProductCard.vue<script setup>
import { RouterLink } from 'vue-router';
import { money } from '../format.js';
defineProps(['product']);
</script>
<template>
<li class="card">
<h2>
<RouterLink :to="`/product/${product.id}`">{{ product.name }}</RouterLink>
</h2>
<p v-if="product.category" class="muted">{{ product.category }}</p>
<p class="price">{{ money(product.price) }}</p>
</li>
</template>
components/Filter.vue<script setup>
import { computed } from 'vue';
const props = defineProps(['categories']);
const q = defineModel('q');
const category = defineModel('category');
// Los productos están en las subcategorías: «Alimentos» y «Hogar» son solo grupos.
const groups = computed(() => props.categories.filter((c) => c.parentId !== null));
</script>
<template>
<form class="filter" role="search" @submit.prevent>
<label>
Búsqueda
<input v-model="q" type="search" />
</label>
<label>
Categoría
<select v-model="category">
<option value="">Todas</option>
<option v-for="c in groups" :key="c.id" :value="String(c.id)">{{ c.name }}</option>
</select>
</label>
</form>
</template>
components/Field.vue<script setup>
const value = defineModel();
defineProps({
name: String,
label: String,
type: { type: String, default: 'text' },
error: String,
});
</script>
<template>
<div class="field">
<label :for="name">{{ label }}</label>
<input
:id="name"
v-model="value"
:name="name"
:type="type"
:aria-invalid="error ? 'true' : undefined"
:aria-describedby="error ? `${name}-error` : undefined"
/>
<span v-if="error" :id="`${name}-error`" class="error">{{ error }}</span>
</div>
</template>
store/cart.jsimport { computed, ref } from 'vue';
import { defineStore } from 'pinia';
export const useCart = defineStore('cart', () => {
const items = ref([]);
const count = computed(() => items.value.reduce((sum, i) => sum + i.qty, 0));
const total = computed(() => items.value.reduce((sum, i) => sum + i.price * i.qty, 0));
function add(product) {
const found = items.value.find((i) => i.id === product.id);
if (found) found.qty += 1;
else items.value.push({ id: product.id, name: product.name, price: product.price, stock: product.stock, qty: 1 });
}
function setQty(id, qty) {
const found = items.value.find((i) => i.id === id);
if (found) found.qty = qty;
}
function remove(id) {
items.value = items.value.filter((i) => i.id !== id);
}
function clear() {
items.value = [];
}
return { items, count, total, add, setQty, remove, clear };
});
api.js// Todas las peticiones a la API de la tienda están aquí: las páginas no conocen las direcciones.
async function request(path, options = {}) {
const res = await fetch(path, options);
const data = await res.json();
if (!res.ok) {
const error = new Error(data.error ?? `El servidor respondió ${res.status}`);
error.status = res.status;
error.errors = data.errors;
throw error;
}
return data;
}
export function getCategories() {
return request('/api/categories');
}
export function getProducts({ q = '', category = '' } = {}, signal) {
const params = new URLSearchParams();
if (q) params.set('q', q);
if (category) params.set('category', category);
return request(`/api/products?${params}`, { signal });
}
export function getProduct(id, signal) {
return request(`/api/products/${id}`, { signal });
}
export function placeOrder(order) {
return request('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(order),
});
}
// La API de prácticas no tiene inicio de sesión y devuelve los pedidos de todos los clientes. Tú eres el cliente 1.
// Un servidor real devuelve solo los tuyos: sabe quién eres por la sesión.
export const ME = 1;
export function getOrders(signal) {
return request('/api/orders', { signal });
}
format.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
export const date = (iso) => new Date(iso).toLocaleDateString('es-ES');
const STATUS = { new: 'Nuevo', paid: 'Pagado', shipped: 'Enviado', delivered: 'Entregado' };
export const statusText = (status) => STATUS[status] ?? status;
style.css* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, sans-serif; color: #1f2328; background: #fbfaf7; }
header { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 16px; padding: 8px 16px; background: #f3efe6; }
header strong { margin-right: auto; }
nav { display: flex; flex-wrap: wrap; gap: 12px; }
nav a { color: inherit; }
nav a[aria-current="page"] { font-weight: 600; text-decoration-thickness: 2px; }
main { padding: 0 16px 24px; }
h1 { font-size: 1.4rem; }
input, select, button { font: inherit; padding: 4px 8px; }
.filter { display: flex; flex-wrap: wrap; gap: 8px 16px; margin-bottom: 12px; }
.filter label { display: flex; flex-direction: column; gap: 2px; font-size: 0.9rem; }
.grid { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 8px; }
.card { background: #fff; border: 1px solid #ddd6c8; border-radius: 8px; padding: 8px 10px; }
.card h2 { font-size: 1rem; margin: 0 0 4px; }
.card p { margin: 0; }
.muted { color: #5c6168; font-size: 0.85rem; }
.price { font-weight: 600; }
.cart, .orders { list-style: none; padding: 0; }
.cart li { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid #ddd6c8; }
.cart li span:first-child { flex: 1; }
.orders li { padding: 6px 0; border-bottom: 1px solid #ddd6c8; }
.orders h2 { font-size: 1rem; margin: 0; }
.orders p { margin: 2px 0; }
.field { display: flex; flex-direction: column; gap: 2px; max-width: 320px; margin-bottom: 10px; }
.field input[aria-invalid="true"] { border-color: #b42318; }
.error { color: #b42318; font-size: 0.85rem; }
La comprobación detecta un error del servidor mostrado encima del formulario en lugar de junto al campo; items: cart.items, donde el servidor no encuentra productId; y un carrito vaciado antes de la respuesta: con un 400 el formulario desaparece, y con él el carrito del comprador.
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».
Consola
Solución
main.jsimport { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import { router } from './router.js';
import './style.css';
createApp(App).use(createPinia()).use(router).mount('#app');
router.jsimport { createMemoryHistory, createRouter } from 'vue-router';
import CartPage from './pages/CartPage.vue';
import CatalogPage from './pages/CatalogPage.vue';
import CheckoutPage from './pages/CheckoutPage.vue';
import NotFoundPage from './pages/NotFoundPage.vue';
import OrdersPage from './pages/OrdersPage.vue';
import ProductPage from './pages/ProductPage.vue';
export const router = createRouter({
// El sandbox no tiene barra de direcciones, así que el historial está en memoria.
// En un proyecto real: createWebHistory().
history: createMemoryHistory(),
routes: [
{ path: '/', component: CatalogPage },
{ path: '/product/:id', component: ProductPage },
{ path: '/cart', component: CartPage },
{ path: '/checkout', component: CheckoutPage },
{ path: '/orders', component: OrdersPage },
{ path: '/:pathMatch(.*)*', component: NotFoundPage },
],
});
App.vue<script setup>
import { RouterLink, RouterView } from 'vue-router';
import { useCart } from './store/cart.js';
const cart = useCart();
</script>
<template>
<header>
<strong>La tienda</strong>
<nav aria-label="Menú principal">
<RouterLink to="/">Catálogo</RouterLink>
<RouterLink to="/cart">Carrito ({{ cart.count }})</RouterLink>
<RouterLink to="/orders">Mis pedidos</RouterLink>
</nav>
</header>
<main>
<RouterView />
</main>
</template>
pages/CatalogPage.vue<script setup>
import { onMounted, ref, watch } from 'vue';
import { getCategories, getProducts } from '../api.js';
import Filter from '../components/Filter.vue';
import ProductCard from '../components/ProductCard.vue';
const q = ref('');
const category = ref('');
const categories = ref([]);
const products = ref(null);
const error = ref(null);
onMounted(async () => {
try {
categories.value = await getCategories();
} catch {
categories.value = [];
}
});
watch(
[q, category],
(now, before, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
products.value = null;
error.value = null;
getProducts({ q: q.value, category: category.value }, controller.signal)
.then((list) => (products.value = list))
.catch((e) => {
if (e.name !== 'AbortError') error.value = e.message;
});
},
{ immediate: true },
);
</script>
<template>
<h1>Catálogo</h1>
<Filter v-model:q="q" v-model:category="category" :categories="categories" />
<p v-if="error" role="alert">{{ error }}</p>
<p v-else-if="!products" role="status">Cargando…</p>
<p v-else-if="products.length === 0">No hay productos que coincidan.</p>
<ul v-else class="grid">
<ProductCard v-for="p in products" :key="p.id" :product="p" />
</ul>
</template>
pages/ProductPage.vue<script setup>
import { computed, watch, ref } from 'vue';
import { RouterLink, useRoute } from 'vue-router';
import { getProduct, getProducts } from '../api.js';
import { money } from '../format.js';
import { useCart } from '../store/cart.js';
const route = useRoute();
const cart = useCart();
const product = ref(null);
const related = ref([]);
const error = ref(null);
const inCart = computed(() => cart.items.find((i) => i.id === product.value?.id)?.qty ?? 0);
async function load(id, signal) {
const p = await getProduct(id, signal);
product.value = p;
if (p.categoryId === null) return;
const same = await getProducts({ category: p.categoryId }, signal);
related.value = same.filter((x) => x.id !== p.id).slice(0, 3);
}
watch(
() => route.params.id,
(id, oldId, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
product.value = null;
related.value = [];
error.value = null;
load(id, controller.signal).catch((e) => {
if (e.name !== 'AbortError') error.value = e.status === 404 ? 'No existe ese producto.' : e.message;
});
},
{ immediate: true },
);
</script>
<template>
<p v-if="error" role="alert">{{ error }}</p>
<p v-else-if="!product" role="status">Cargando…</p>
<article v-else>
<p>
<RouterLink to="/">← Volver al catálogo</RouterLink>
</p>
<h1>{{ product.name }}</h1>
<p v-if="product.category" class="muted">{{ product.category }}</p>
<p class="price">{{ money(product.price) }}</p>
<p v-if="product.stock > 0">En existencias: {{ product.stock }} uds.</p>
<p v-else>Agotado</p>
<button type="button" :disabled="product.stock === 0" @click="cart.add(product)">
Añadir al carrito
</button>
<p v-if="inCart > 0" role="status">En el carrito: {{ inCart }}</p>
<section v-if="related.length > 0" aria-labelledby="related-title">
<h2 id="related-title">Más de esta categoría</h2>
<ul>
<li v-for="p in related" :key="p.id">
<RouterLink :to="`/product/${p.id}`">{{ p.name }}</RouterLink>
</li>
</ul>
</section>
</article>
</template>
pages/CartPage.vue<script setup>
import { RouterLink } from 'vue-router';
import { money } from '../format.js';
import { useCart } from '../store/cart.js';
const cart = useCart();
const upTo = (n) => Array.from({ length: Math.min(n, 10) }, (_, i) => i + 1);
</script>
<template>
<h1>Carrito</h1>
<template v-if="cart.items.length === 0">
<p>El carrito está vacío.</p>
<p>
<RouterLink to="/">Volver al catálogo</RouterLink>
</p>
</template>
<template v-else>
<ul class="cart">
<li v-for="item in cart.items" :key="item.id">
<span>{{ item.name }}</span>
<select
:aria-label="`Cantidad: ${item.name}`"
:value="item.qty"
@change="cart.setQty(item.id, Number($event.target.value))"
>
<option v-for="n in upTo(item.stock)" :key="n">{{ n }}</option>
</select>
<span>{{ money(item.price * item.qty) }}</span>
<button type="button" :aria-label="`Quitar ${item.name}`" @click="cart.remove(item.id)">
Quitar
</button>
</li>
</ul>
<p class="price">Total: {{ money(cart.total) }}</p>
<RouterLink to="/checkout">Hacer el pedido</RouterLink>
</template>
</template>
pages/CheckoutPage.vue<script setup>
import { reactive, ref } from 'vue';
import { RouterLink } from 'vue-router';
import { placeOrder } from '../api.js';
import Field from '../components/Field.vue';
import { money } from '../format.js';
import { useCart } from '../store/cart.js';
const cart = useCart();
const form = reactive({ name: '', email: '', address: '' });
const errors = ref({});
const sending = ref(false);
const order = ref(null);
function check() {
const found = {};
if (!form.name.trim()) found.name = 'Escribe tu nombre.';
if (!form.email.includes('@')) found.email = 'Escribe tu correo electrónico.';
if (!form.address.trim()) found.address = 'Escribe tu dirección.';
return found;
}
async function submit() {
errors.value = check();
if (Object.keys(errors.value).length > 0) return;
sending.value = true;
try {
const items = cart.items.map((i) => ({ productId: i.id, qty: i.qty }));
order.value = await placeOrder({ ...form, items });
cart.clear();
} catch (err) {
errors.value = err.errors ?? { form: err.message };
} finally {
sending.value = false;
}
}
</script>
<template>
<template v-if="order">
<h1>Pedido recibido</h1>
<p role="status">Pedido n.º {{ order.id }} por {{ money(order.total) }}.</p>
<p>
<RouterLink to="/orders">Mis pedidos</RouterLink>
</p>
</template>
<template v-else-if="cart.items.length === 0">
<h1>Datos del pedido</h1>
<p>El carrito está vacío.</p>
</template>
<template v-else>
<h1>Datos del pedido</h1>
<p class="price">Total: {{ money(cart.total) }}</p>
<form novalidate @submit.prevent="submit">
<Field v-model="form.name" name="name" label="Nombre" :error="errors.name" />
<Field v-model="form.email" name="email" label="Correo electrónico" type="email" :error="errors.email" />
<Field v-model="form.address" name="address" label="Dirección" :error="errors.address" />
<p v-if="errors.items" role="alert">{{ errors.items }}</p>
<p v-if="errors.form" role="alert">{{ errors.form }}</p>
<button :disabled="sending">{{ sending ? 'Enviando…' : 'Confirmar pedido' }}</button>
</form>
</template>
</template>
pages/OrdersPage.vue<script setup>
import { onMounted, onUnmounted, ref } from 'vue';
import { ME, getOrders } from '../api.js';
import { date, money, statusText } from '../format.js';
const orders = ref(null);
const error = ref(null);
const controller = new AbortController();
onMounted(async () => {
try {
const all = await getOrders(controller.signal);
orders.value = all.filter((o) => o.customerId === ME).sort((a, b) => b.id - a.id);
} catch (e) {
if (e.name !== 'AbortError') error.value = e.message;
}
});
onUnmounted(() => controller.abort());
</script>
<template>
<h1 id="orders-title">Mis pedidos</h1>
<p v-if="error" role="alert">{{ error }}</p>
<p v-else-if="!orders" role="status">Cargando…</p>
<p v-else-if="orders.length === 0">Todavía no tienes pedidos.</p>
<ul v-else class="orders" aria-labelledby="orders-title">
<li v-for="o in orders" :key="o.id">
<h2>Pedido n.º {{ o.id }}</h2>
<p class="muted">{{ date(o.date) }} · {{ statusText(o.status) }} · {{ money(o.total) }}</p>
<p>{{ o.items.map((i) => `${i.name} × ${i.qty}`).join(', ') }}</p>
</li>
</ul>
</template>
pages/NotFoundPage.vue<script setup>
import { RouterLink } from 'vue-router';
</script>
<template>
<h1>Esa página no existe</h1>
<p>
<RouterLink to="/">Volver al catálogo</RouterLink>
</p>
</template>
components/ProductCard.vue<script setup>
import { RouterLink } from 'vue-router';
import { money } from '../format.js';
defineProps(['product']);
</script>
<template>
<li class="card">
<h2>
<RouterLink :to="`/product/${product.id}`">{{ product.name }}</RouterLink>
</h2>
<p v-if="product.category" class="muted">{{ product.category }}</p>
<p class="price">{{ money(product.price) }}</p>
</li>
</template>
components/Filter.vue<script setup>
import { computed } from 'vue';
const props = defineProps(['categories']);
const q = defineModel('q');
const category = defineModel('category');
// Los productos están en las subcategorías: «Alimentos» y «Hogar» son solo grupos.
const groups = computed(() => props.categories.filter((c) => c.parentId !== null));
</script>
<template>
<form class="filter" role="search" @submit.prevent>
<label>
Búsqueda
<input v-model="q" type="search" />
</label>
<label>
Categoría
<select v-model="category">
<option value="">Todas</option>
<option v-for="c in groups" :key="c.id" :value="String(c.id)">{{ c.name }}</option>
</select>
</label>
</form>
</template>
components/Field.vue<script setup>
const value = defineModel();
defineProps({
name: String,
label: String,
type: { type: String, default: 'text' },
error: String,
});
</script>
<template>
<div class="field">
<label :for="name">{{ label }}</label>
<input
:id="name"
v-model="value"
:name="name"
:type="type"
:aria-invalid="error ? 'true' : undefined"
:aria-describedby="error ? `${name}-error` : undefined"
/>
<span v-if="error" :id="`${name}-error`" class="error">{{ error }}</span>
</div>
</template>
store/cart.jsimport { computed, ref } from 'vue';
import { defineStore } from 'pinia';
export const useCart = defineStore('cart', () => {
const items = ref([]);
const count = computed(() => items.value.reduce((sum, i) => sum + i.qty, 0));
const total = computed(() => items.value.reduce((sum, i) => sum + i.price * i.qty, 0));
function add(product) {
const found = items.value.find((i) => i.id === product.id);
if (found) found.qty += 1;
else items.value.push({ id: product.id, name: product.name, price: product.price, stock: product.stock, qty: 1 });
}
function setQty(id, qty) {
const found = items.value.find((i) => i.id === id);
if (found) found.qty = qty;
}
function remove(id) {
items.value = items.value.filter((i) => i.id !== id);
}
function clear() {
items.value = [];
}
return { items, count, total, add, setQty, remove, clear };
});
api.js// Todas las peticiones a la API de la tienda están aquí: las páginas no conocen las direcciones.
async function request(path, options = {}) {
const res = await fetch(path, options);
const data = await res.json();
if (!res.ok) {
const error = new Error(data.error ?? `El servidor respondió ${res.status}`);
error.status = res.status;
error.errors = data.errors;
throw error;
}
return data;
}
export function getCategories() {
return request('/api/categories');
}
export function getProducts({ q = '', category = '' } = {}, signal) {
const params = new URLSearchParams();
if (q) params.set('q', q);
if (category) params.set('category', category);
return request(`/api/products?${params}`, { signal });
}
export function getProduct(id, signal) {
return request(`/api/products/${id}`, { signal });
}
export function placeOrder(order) {
return request('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(order),
});
}
// La API de prácticas no tiene inicio de sesión y devuelve los pedidos de todos los clientes. Tú eres el cliente 1.
// Un servidor real devuelve solo los tuyos: sabe quién eres por la sesión.
export const ME = 1;
export function getOrders(signal) {
return request('/api/orders', { signal });
}
format.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
export const date = (iso) => new Date(iso).toLocaleDateString('es-ES');
const STATUS = { new: 'Nuevo', paid: 'Pagado', shipped: 'Enviado', delivered: 'Entregado' };
export const statusText = (status) => STATUS[status] ?? status;
style.css* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, sans-serif; color: #1f2328; background: #fbfaf7; }
header { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 16px; padding: 8px 16px; background: #f3efe6; }
header strong { margin-right: auto; }
nav { display: flex; flex-wrap: wrap; gap: 12px; }
nav a { color: inherit; }
nav a[aria-current="page"] { font-weight: 600; text-decoration-thickness: 2px; }
main { padding: 0 16px 24px; }
h1 { font-size: 1.4rem; }
input, select, button { font: inherit; padding: 4px 8px; }
.filter { display: flex; flex-wrap: wrap; gap: 8px 16px; margin-bottom: 12px; }
.filter label { display: flex; flex-direction: column; gap: 2px; font-size: 0.9rem; }
.grid { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 8px; }
.card { background: #fff; border: 1px solid #ddd6c8; border-radius: 8px; padding: 8px 10px; }
.card h2 { font-size: 1rem; margin: 0 0 4px; }
.card p { margin: 0; }
.muted { color: #5c6168; font-size: 0.85rem; }
.price { font-weight: 600; }
.cart, .orders { list-style: none; padding: 0; }
.cart li { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid #ddd6c8; }
.cart li span:first-child { flex: 1; }
.orders li { padding: 6px 0; border-bottom: 1px solid #ddd6c8; }
.orders h2 { font-size: 1rem; margin: 0; }
.orders p { margin: 2px 0; }
.field { display: flex; flex-direction: column; gap: 2px; max-width: 320px; margin-bottom: 10px; }
.field input[aria-invalid="true"] { border-color: #b42318; }
.error { color: #b42318; font-size: 0.85rem; }
La comprobación detecta un api.js que guarda la primera respuesta de /api/orders y no vuelve a preguntar (el pedido nuevo no aparece), una lista sin ordenar (el nuevo queda abajo) y una lista sin el filtro por cliente (60 pedidos de otras personas).
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
productIdyqty. 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
localStorageo en el servidor. - Las direcciones son reales.
createWebHistory()en lugar del historial en memoria, y un hosting que devuelveindex.htmlpara 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»:
npm create vite@latest shop -- --template vuecd shopnpm install vue-router piniaCopia 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/:idconGET /api/orders/:idy un enlace desde «Mis pedidos». Un número desconocido muestra «No existe ese pedido.» - Un carrito que sobrevive. Guarda
itemsenlocalStoragey léelos al arrancar. - Tests. Un test para
CartPage: cambiar una cantidad cambia el total. Un test paraCheckoutPage: un formulario vacío muestra los tres errores y no envía ninguna petición.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Estado compartido · Enrutamiento · Probar componentes · Del sandbox al proyecto
- Ejercicios sobre el tema: Proyecto: el escaparate de la tienda