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 main.jsx: MemoryRouter alrededor de CartProvider y App. Las direcciones están en App.jsx con Routes y Route, 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 BrowserRouter. 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. El efecto tiene
[q, category]en sus dependencias. - 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 la limpieza del efecto.
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.jsximport { createRoot } from 'react-dom/client';
import { MemoryRouter } from 'react-router';
import App from './App.jsx';
import { CartProvider } from './store/cart.jsx';
import './style.css';
// El sandbox no tiene barra de direcciones, así que el historial está en memoria.
// En un proyecto real: BrowserRouter.
createRoot(document.getElementById('root')).render(
<MemoryRouter>
<CartProvider>
<App />
</CartProvider>
</MemoryRouter>,
);
App.jsximport { NavLink, Route, Routes } from 'react-router';
import CartPage from './pages/CartPage.jsx';
import CatalogPage from './pages/CatalogPage.jsx';
import CheckoutPage from './pages/CheckoutPage.jsx';
import NotFoundPage from './pages/NotFoundPage.jsx';
import OrdersPage from './pages/OrdersPage.jsx';
import ProductPage from './pages/ProductPage.jsx';
import { useCart } from './store/cart.jsx';
export default function App() {
const cart = useCart();
return (
<>
<header>
<strong>La tienda</strong>
<nav aria-label="Menú principal">
<NavLink to="/" end>Catálogo</NavLink>
<NavLink to="/cart">Carrito ({cart.count})</NavLink>
<NavLink to="/orders">Mis pedidos</NavLink>
</nav>
</header>
<main>
<Routes>
<Route path="/" element={<CatalogPage />} />
<Route path="/product/:id" element={<ProductPage />} />
<Route path="/cart" element={<CartPage />} />
<Route path="/checkout" element={<CheckoutPage />} />
<Route path="/orders" element={<OrdersPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</main>
</>
);
}
pages/CatalogPage.jsximport { useEffect, useState } from 'react';
import { getCategories, getProducts } from '../api.js';
import Filter from '../components/Filter.jsx';
import ProductCard from '../components/ProductCard.jsx';
export default function CatalogPage() {
const [q, setQ] = useState('');
const [category, setCategory] = useState('');
const [categories, setCategories] = useState([]);
const [products, setProducts] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
getCategories()
.then(setCategories)
.catch(() => setCategories([]));
}, []);
useEffect(() => {
const controller = new AbortController();
setProducts(null);
setError(null);
getProducts({ q, category }, controller.signal)
.then(setProducts)
.catch((e) => {
if (e.name !== 'AbortError') setError(e.message);
});
return () => controller.abort();
}, [q, category]);
return (
<>
<h1>Catálogo</h1>
<Filter categories={categories} q={q} category={category} onQChange={setQ} onCategoryChange={setCategory} />
{error && <p role="alert">{error}</p>}
{!error && !products && <p role="status">Cargando…</p>}
{products && products.length === 0 && <p>No hay productos que coincidan.</p>}
{products && products.length > 0 && (
<ul className="grid">
{products.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</ul>
)}
</>
);
}
pages/ProductPage.jsxexport default function ProductPage() {
return (
<>
<h1>Producto</h1>
<p>Aquí va el producto.</p>
</>
);
}
pages/CartPage.jsxexport default function CartPage() {
return (
<>
<h1>Carrito</h1>
<p>Aquí va el carrito.</p>
</>
);
}
pages/CheckoutPage.jsxexport default function CheckoutPage() {
return (
<>
<h1>Datos del pedido</h1>
<p>Aquí va el pedido.</p>
</>
);
}
pages/OrdersPage.jsxexport default function OrdersPage() {
return (
<>
<h1>Mis pedidos</h1>
<p>Aquí van tus pedidos.</p>
</>
);
}
pages/NotFoundPage.jsximport { Link } from 'react-router';
export default function NotFoundPage() {
return (
<>
<h1>Esa página no existe</h1>
<p>
<Link to="/">Volver al catálogo</Link>
</p>
</>
);
}
components/ProductCard.jsximport { money } from '../format.js';
export default function ProductCard({ product }) {
return (
<li className="card">
<h2>{product.name}</h2>
{product.category && <p className="muted">{product.category}</p>}
<p className="price">{money(product.price)}</p>
</li>
);
}
components/Filter.jsxexport default function Filter({ categories, q, category, onQChange, onCategoryChange }) {
// Los productos están en las subcategorías: «Alimentos» y «Hogar» son solo grupos.
const groups = categories.filter((c) => c.parentId !== null);
return (
<form className="filter" role="search" onSubmit={(e) => e.preventDefault()}>
<label>
Búsqueda
<input type="search" value={q} onChange={(e) => onQChange(e.target.value)} />
</label>
<label>
Categoría
<select value={category} onChange={(e) => onCategoryChange(e.target.value)}>
<option value="">Todas</option>
{groups.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
</form>
);
}
components/Field.jsxexport default function Field({ name, label, type = 'text', value, error, onChange }) {
const errorId = `${name}-error`;
return (
<div className="field">
<label htmlFor={name}>{label}</label>
<input
id={name}
name={name}
type={type}
value={value}
onChange={onChange}
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? errorId : undefined}
/>
{error && (
<span id={errorId} className="error">
{error}
</span>
)}
</div>
);
}
store/cart.jsx// El carrito llega en el paso 3. Hasta entonces está vacío y no cambia.
export function CartProvider({ children }) {
return children;
}
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 dependencias; 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:
<Link 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. El efecto tiene[id]. - 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.jsximport { createRoot } from 'react-dom/client';
import { MemoryRouter } from 'react-router';
import App from './App.jsx';
import { CartProvider } from './store/cart.jsx';
import './style.css';
// El sandbox no tiene barra de direcciones, así que el historial está en memoria.
// En un proyecto real: BrowserRouter.
createRoot(document.getElementById('root')).render(
<MemoryRouter>
<CartProvider>
<App />
</CartProvider>
</MemoryRouter>,
);
App.jsximport { NavLink, Route, Routes } from 'react-router';
import CartPage from './pages/CartPage.jsx';
import CatalogPage from './pages/CatalogPage.jsx';
import CheckoutPage from './pages/CheckoutPage.jsx';
import NotFoundPage from './pages/NotFoundPage.jsx';
import OrdersPage from './pages/OrdersPage.jsx';
import ProductPage from './pages/ProductPage.jsx';
import { useCart } from './store/cart.jsx';
export default function App() {
const cart = useCart();
return (
<>
<header>
<strong>La tienda</strong>
<nav aria-label="Menú principal">
<NavLink to="/" end>Catálogo</NavLink>
<NavLink to="/cart">Carrito ({cart.count})</NavLink>
<NavLink to="/orders">Mis pedidos</NavLink>
</nav>
</header>
<main>
<Routes>
<Route path="/" element={<CatalogPage />} />
<Route path="/product/:id" element={<ProductPage />} />
<Route path="/cart" element={<CartPage />} />
<Route path="/checkout" element={<CheckoutPage />} />
<Route path="/orders" element={<OrdersPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</main>
</>
);
}
pages/CatalogPage.jsximport { useEffect, useState } from 'react';
import { getCategories, getProducts } from '../api.js';
import Filter from '../components/Filter.jsx';
import ProductCard from '../components/ProductCard.jsx';
export default function CatalogPage() {
const [q, setQ] = useState('');
const [category, setCategory] = useState('');
const [categories, setCategories] = useState([]);
const [products, setProducts] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
getCategories()
.then(setCategories)
.catch(() => setCategories([]));
}, []);
useEffect(() => {
const controller = new AbortController();
setProducts(null);
setError(null);
getProducts({ q, category }, controller.signal)
.then(setProducts)
.catch((e) => {
if (e.name !== 'AbortError') setError(e.message);
});
return () => controller.abort();
}, [q, category]);
return (
<>
<h1>Catálogo</h1>
<Filter categories={categories} q={q} category={category} onQChange={setQ} onCategoryChange={setCategory} />
{error && <p role="alert">{error}</p>}
{!error && !products && <p role="status">Cargando…</p>}
{products && products.length === 0 && <p>No hay productos que coincidan.</p>}
{products && products.length > 0 && (
<ul className="grid">
{products.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</ul>
)}
</>
);
}
pages/ProductPage.jsximport { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router';
import { getProduct, getProducts } from '../api.js';
import { money } from '../format.js';
export default function ProductPage() {
const { id } = useParams();
const [product, setProduct] = useState(null);
const [related, setRelated] = useState([]);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
async function load() {
const p = await getProduct(id, controller.signal);
setProduct(p);
if (p.categoryId === null) return;
const same = await getProducts({ category: p.categoryId }, controller.signal);
setRelated(same.filter((x) => x.id !== p.id).slice(0, 3));
}
setProduct(null);
setRelated([]);
setError(null);
load().catch((e) => {
if (e.name !== 'AbortError') setError(e.status === 404 ? 'No existe ese producto.' : e.message);
});
return () => controller.abort();
}, [id]);
if (error) return <p role="alert">{error}</p>;
if (!product) return <p role="status">Cargando…</p>;
return (
<article>
<p>
<Link to="/">← Volver al catálogo</Link>
</p>
<h1>{product.name}</h1>
{product.category && <p className="muted">{product.category}</p>}
<p className="price">{money(product.price)}</p>
<p>{product.stock > 0 ? `En existencias: ${product.stock} uds.` : 'Agotado'}</p>
{related.length > 0 && (
<section aria-labelledby="related-title">
<h2 id="related-title">Más de esta categoría</h2>
<ul>
{related.map((p) => (
<li key={p.id}>
<Link to={`/product/${p.id}`}>{p.name}</Link>
</li>
))}
</ul>
</section>
)}
</article>
);
}
pages/CartPage.jsxexport default function CartPage() {
return (
<>
<h1>Carrito</h1>
<p>Aquí va el carrito.</p>
</>
);
}
pages/CheckoutPage.jsxexport default function CheckoutPage() {
return (
<>
<h1>Datos del pedido</h1>
<p>Aquí va el pedido.</p>
</>
);
}
pages/OrdersPage.jsxexport default function OrdersPage() {
return (
<>
<h1>Mis pedidos</h1>
<p>Aquí van tus pedidos.</p>
</>
);
}
pages/NotFoundPage.jsximport { Link } from 'react-router';
export default function NotFoundPage() {
return (
<>
<h1>Esa página no existe</h1>
<p>
<Link to="/">Volver al catálogo</Link>
</p>
</>
);
}
components/ProductCard.jsximport { Link } from 'react-router';
import { money } from '../format.js';
export default function ProductCard({ product }) {
return (
<li className="card">
<h2>
<Link to={`/product/${product.id}`}>{product.name}</Link>
</h2>
{product.category && <p className="muted">{product.category}</p>}
<p className="price">{money(product.price)}</p>
</li>
);
}
components/Filter.jsxexport default function Filter({ categories, q, category, onQChange, onCategoryChange }) {
// Los productos están en las subcategorías: «Alimentos» y «Hogar» son solo grupos.
const groups = categories.filter((c) => c.parentId !== null);
return (
<form className="filter" role="search" onSubmit={(e) => e.preventDefault()}>
<label>
Búsqueda
<input type="search" value={q} onChange={(e) => onQChange(e.target.value)} />
</label>
<label>
Categoría
<select value={category} onChange={(e) => onCategoryChange(e.target.value)}>
<option value="">Todas</option>
{groups.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
</form>
);
}
components/Field.jsxexport default function Field({ name, label, type = 'text', value, error, onChange }) {
const errorId = `${name}-error`;
return (
<div className="field">
<label htmlFor={name}>{label}</label>
<input
id={name}
name={name}
type={type}
value={value}
onChange={onChange}
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? errorId : undefined}
/>
{error && (
<span id={errorId} className="error">
{error}
</span>
)}
</div>
);
}
store/cart.jsx// El carrito llega en el paso 3. Hasta entonces está vacío y no cambia.
export function CartProvider({ children }) {
return children;
}
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.jsx tiene un CartProvider con useState y un CartContext, como en la lección sobre el estado compartido. useCart() devuelve useContext(CartContext): items, count, total, add, setQty, remove, clear. count y total se calculan a partir de items, no se guardan por separado. Cada cambio crea un array nuevo: [...items, …], items.map(…), items.filter(…).
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.jsximport { createRoot } from 'react-dom/client';
import { MemoryRouter } from 'react-router';
import App from './App.jsx';
import { CartProvider } from './store/cart.jsx';
import './style.css';
// El sandbox no tiene barra de direcciones, así que el historial está en memoria.
// En un proyecto real: BrowserRouter.
createRoot(document.getElementById('root')).render(
<MemoryRouter>
<CartProvider>
<App />
</CartProvider>
</MemoryRouter>,
);
App.jsximport { NavLink, Route, Routes } from 'react-router';
import CartPage from './pages/CartPage.jsx';
import CatalogPage from './pages/CatalogPage.jsx';
import CheckoutPage from './pages/CheckoutPage.jsx';
import NotFoundPage from './pages/NotFoundPage.jsx';
import OrdersPage from './pages/OrdersPage.jsx';
import ProductPage from './pages/ProductPage.jsx';
import { useCart } from './store/cart.jsx';
export default function App() {
const cart = useCart();
return (
<>
<header>
<strong>La tienda</strong>
<nav aria-label="Menú principal">
<NavLink to="/" end>Catálogo</NavLink>
<NavLink to="/cart">Carrito ({cart.count})</NavLink>
<NavLink to="/orders">Mis pedidos</NavLink>
</nav>
</header>
<main>
<Routes>
<Route path="/" element={<CatalogPage />} />
<Route path="/product/:id" element={<ProductPage />} />
<Route path="/cart" element={<CartPage />} />
<Route path="/checkout" element={<CheckoutPage />} />
<Route path="/orders" element={<OrdersPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</main>
</>
);
}
pages/CatalogPage.jsximport { useEffect, useState } from 'react';
import { getCategories, getProducts } from '../api.js';
import Filter from '../components/Filter.jsx';
import ProductCard from '../components/ProductCard.jsx';
export default function CatalogPage() {
const [q, setQ] = useState('');
const [category, setCategory] = useState('');
const [categories, setCategories] = useState([]);
const [products, setProducts] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
getCategories()
.then(setCategories)
.catch(() => setCategories([]));
}, []);
useEffect(() => {
const controller = new AbortController();
setProducts(null);
setError(null);
getProducts({ q, category }, controller.signal)
.then(setProducts)
.catch((e) => {
if (e.name !== 'AbortError') setError(e.message);
});
return () => controller.abort();
}, [q, category]);
return (
<>
<h1>Catálogo</h1>
<Filter categories={categories} q={q} category={category} onQChange={setQ} onCategoryChange={setCategory} />
{error && <p role="alert">{error}</p>}
{!error && !products && <p role="status">Cargando…</p>}
{products && products.length === 0 && <p>No hay productos que coincidan.</p>}
{products && products.length > 0 && (
<ul className="grid">
{products.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</ul>
)}
</>
);
}
pages/ProductPage.jsximport { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router';
import { getProduct, getProducts } from '../api.js';
import { money } from '../format.js';
import { useCart } from '../store/cart.jsx';
export default function ProductPage() {
const { id } = useParams();
const cart = useCart();
const [product, setProduct] = useState(null);
const [related, setRelated] = useState([]);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
async function load() {
const p = await getProduct(id, controller.signal);
setProduct(p);
if (p.categoryId === null) return;
const same = await getProducts({ category: p.categoryId }, controller.signal);
setRelated(same.filter((x) => x.id !== p.id).slice(0, 3));
}
setProduct(null);
setRelated([]);
setError(null);
load().catch((e) => {
if (e.name !== 'AbortError') setError(e.status === 404 ? 'No existe ese producto.' : e.message);
});
return () => controller.abort();
}, [id]);
if (error) return <p role="alert">{error}</p>;
if (!product) return <p role="status">Cargando…</p>;
const inCart = cart.items.find((i) => i.id === product.id)?.qty ?? 0;
return (
<article>
<p>
<Link to="/">← Volver al catálogo</Link>
</p>
<h1>{product.name}</h1>
{product.category && <p className="muted">{product.category}</p>}
<p className="price">{money(product.price)}</p>
<p>{product.stock > 0 ? `En existencias: ${product.stock} uds.` : 'Agotado'}</p>
<button type="button" disabled={product.stock === 0} onClick={() => cart.add(product)}>
Añadir al carrito
</button>
{inCart > 0 && <p role="status">En el carrito: {inCart}</p>}
{related.length > 0 && (
<section aria-labelledby="related-title">
<h2 id="related-title">Más de esta categoría</h2>
<ul>
{related.map((p) => (
<li key={p.id}>
<Link to={`/product/${p.id}`}>{p.name}</Link>
</li>
))}
</ul>
</section>
)}
</article>
);
}
pages/CartPage.jsximport { Link } from 'react-router';
import { money } from '../format.js';
import { useCart } from '../store/cart.jsx';
const upTo = (n) => Array.from({ length: Math.min(n, 10) }, (_, i) => i + 1);
export default function CartPage() {
const cart = useCart();
if (cart.items.length === 0)
return (
<>
<h1>Carrito</h1>
<p>El carrito está vacío.</p>
<p>
<Link to="/">Volver al catálogo</Link>
</p>
</>
);
return (
<>
<h1>Carrito</h1>
<ul className="cart">
{cart.items.map((item) => (
<li key={item.id}>
<span>{item.name}</span>
<select
aria-label={`Cantidad: ${item.name}`}
value={item.qty}
onChange={(e) => cart.setQty(item.id, Number(e.target.value))}
>
{upTo(item.stock).map((n) => (
<option key={n}>{n}</option>
))}
</select>
<span>{money(item.price * item.qty)}</span>
<button type="button" aria-label={`Quitar ${item.name}`} onClick={() => cart.remove(item.id)}>
Quitar
</button>
</li>
))}
</ul>
<p className="price">
Total: {money(cart.total)}
</p>
<Link to="/checkout">Hacer el pedido</Link>
</>
);
}
pages/CheckoutPage.jsxexport default function CheckoutPage() {
return (
<>
<h1>Datos del pedido</h1>
<p>Aquí va el pedido.</p>
</>
);
}
pages/OrdersPage.jsxexport default function OrdersPage() {
return (
<>
<h1>Mis pedidos</h1>
<p>Aquí van tus pedidos.</p>
</>
);
}
pages/NotFoundPage.jsximport { Link } from 'react-router';
export default function NotFoundPage() {
return (
<>
<h1>Esa página no existe</h1>
<p>
<Link to="/">Volver al catálogo</Link>
</p>
</>
);
}
components/ProductCard.jsximport { Link } from 'react-router';
import { money } from '../format.js';
export default function ProductCard({ product }) {
return (
<li className="card">
<h2>
<Link to={`/product/${product.id}`}>{product.name}</Link>
</h2>
{product.category && <p className="muted">{product.category}</p>}
<p className="price">{money(product.price)}</p>
</li>
);
}
components/Filter.jsxexport default function Filter({ categories, q, category, onQChange, onCategoryChange }) {
// Los productos están en las subcategorías: «Alimentos» y «Hogar» son solo grupos.
const groups = categories.filter((c) => c.parentId !== null);
return (
<form className="filter" role="search" onSubmit={(e) => e.preventDefault()}>
<label>
Búsqueda
<input type="search" value={q} onChange={(e) => onQChange(e.target.value)} />
</label>
<label>
Categoría
<select value={category} onChange={(e) => onCategoryChange(e.target.value)}>
<option value="">Todas</option>
{groups.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
</form>
);
}
components/Field.jsxexport default function Field({ name, label, type = 'text', value, error, onChange }) {
const errorId = `${name}-error`;
return (
<div className="field">
<label htmlFor={name}>{label}</label>
<input
id={name}
name={name}
type={type}
value={value}
onChange={onChange}
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? errorId : undefined}
/>
{error && (
<span id={errorId} className="error">
{error}
</span>
)}
</div>
);
}
store/cart.jsximport { createContext, useContext, useState } from 'react';
const CartContext = createContext(null);
export function CartProvider({ children }) {
const [items, setItems] = useState([]);
function add(product) {
const found = items.find((i) => i.id === product.id);
if (found) setItems(items.map((i) => (i.id === product.id ? { ...i, qty: i.qty + 1 } : i)));
else setItems([...items, { id: product.id, name: product.name, price: product.price, stock: product.stock, qty: 1 }]);
}
function setQty(id, qty) {
setItems(items.map((i) => (i.id === id ? { ...i, qty } : i)));
}
function remove(id) {
setItems(items.filter((i) => i.id !== id));
}
function clear() {
setItems([]);
}
const count = items.reduce((sum, i) => sum + i.qty, 0);
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
return <CartContext value={{ items, count, total, add, setQty, remove, clear }}>{children}</CartContext>;
}
export function useCart() {
return useContext(CartContext);
}
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 Context (cada componente tiene su propio carrito y el menú dice 0), un total en un useState aparte que cambiar una cantidad no actualiza, y un add que cambia el array en el sitio y le da a setItems el mismo array: React ve el mismo array y no vuelve a renderizar.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.jsximport { createRoot } from 'react-dom/client';
import { MemoryRouter } from 'react-router';
import App from './App.jsx';
import { CartProvider } from './store/cart.jsx';
import './style.css';
// El sandbox no tiene barra de direcciones, así que el historial está en memoria.
// En un proyecto real: BrowserRouter.
createRoot(document.getElementById('root')).render(
<MemoryRouter>
<CartProvider>
<App />
</CartProvider>
</MemoryRouter>,
);
App.jsximport { NavLink, Route, Routes } from 'react-router';
import CartPage from './pages/CartPage.jsx';
import CatalogPage from './pages/CatalogPage.jsx';
import CheckoutPage from './pages/CheckoutPage.jsx';
import NotFoundPage from './pages/NotFoundPage.jsx';
import OrdersPage from './pages/OrdersPage.jsx';
import ProductPage from './pages/ProductPage.jsx';
import { useCart } from './store/cart.jsx';
export default function App() {
const cart = useCart();
return (
<>
<header>
<strong>La tienda</strong>
<nav aria-label="Menú principal">
<NavLink to="/" end>Catálogo</NavLink>
<NavLink to="/cart">Carrito ({cart.count})</NavLink>
<NavLink to="/orders">Mis pedidos</NavLink>
</nav>
</header>
<main>
<Routes>
<Route path="/" element={<CatalogPage />} />
<Route path="/product/:id" element={<ProductPage />} />
<Route path="/cart" element={<CartPage />} />
<Route path="/checkout" element={<CheckoutPage />} />
<Route path="/orders" element={<OrdersPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</main>
</>
);
}
pages/CatalogPage.jsximport { useEffect, useState } from 'react';
import { getCategories, getProducts } from '../api.js';
import Filter from '../components/Filter.jsx';
import ProductCard from '../components/ProductCard.jsx';
export default function CatalogPage() {
const [q, setQ] = useState('');
const [category, setCategory] = useState('');
const [categories, setCategories] = useState([]);
const [products, setProducts] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
getCategories()
.then(setCategories)
.catch(() => setCategories([]));
}, []);
useEffect(() => {
const controller = new AbortController();
setProducts(null);
setError(null);
getProducts({ q, category }, controller.signal)
.then(setProducts)
.catch((e) => {
if (e.name !== 'AbortError') setError(e.message);
});
return () => controller.abort();
}, [q, category]);
return (
<>
<h1>Catálogo</h1>
<Filter categories={categories} q={q} category={category} onQChange={setQ} onCategoryChange={setCategory} />
{error && <p role="alert">{error}</p>}
{!error && !products && <p role="status">Cargando…</p>}
{products && products.length === 0 && <p>No hay productos que coincidan.</p>}
{products && products.length > 0 && (
<ul className="grid">
{products.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</ul>
)}
</>
);
}
pages/ProductPage.jsximport { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router';
import { getProduct, getProducts } from '../api.js';
import { money } from '../format.js';
import { useCart } from '../store/cart.jsx';
export default function ProductPage() {
const { id } = useParams();
const cart = useCart();
const [product, setProduct] = useState(null);
const [related, setRelated] = useState([]);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
async function load() {
const p = await getProduct(id, controller.signal);
setProduct(p);
if (p.categoryId === null) return;
const same = await getProducts({ category: p.categoryId }, controller.signal);
setRelated(same.filter((x) => x.id !== p.id).slice(0, 3));
}
setProduct(null);
setRelated([]);
setError(null);
load().catch((e) => {
if (e.name !== 'AbortError') setError(e.status === 404 ? 'No existe ese producto.' : e.message);
});
return () => controller.abort();
}, [id]);
if (error) return <p role="alert">{error}</p>;
if (!product) return <p role="status">Cargando…</p>;
const inCart = cart.items.find((i) => i.id === product.id)?.qty ?? 0;
return (
<article>
<p>
<Link to="/">← Volver al catálogo</Link>
</p>
<h1>{product.name}</h1>
{product.category && <p className="muted">{product.category}</p>}
<p className="price">{money(product.price)}</p>
<p>{product.stock > 0 ? `En existencias: ${product.stock} uds.` : 'Agotado'}</p>
<button type="button" disabled={product.stock === 0} onClick={() => cart.add(product)}>
Añadir al carrito
</button>
{inCart > 0 && <p role="status">En el carrito: {inCart}</p>}
{related.length > 0 && (
<section aria-labelledby="related-title">
<h2 id="related-title">Más de esta categoría</h2>
<ul>
{related.map((p) => (
<li key={p.id}>
<Link to={`/product/${p.id}`}>{p.name}</Link>
</li>
))}
</ul>
</section>
)}
</article>
);
}
pages/CartPage.jsximport { Link } from 'react-router';
import { money } from '../format.js';
import { useCart } from '../store/cart.jsx';
const upTo = (n) => Array.from({ length: Math.min(n, 10) }, (_, i) => i + 1);
export default function CartPage() {
const cart = useCart();
if (cart.items.length === 0)
return (
<>
<h1>Carrito</h1>
<p>El carrito está vacío.</p>
<p>
<Link to="/">Volver al catálogo</Link>
</p>
</>
);
return (
<>
<h1>Carrito</h1>
<ul className="cart">
{cart.items.map((item) => (
<li key={item.id}>
<span>{item.name}</span>
<select
aria-label={`Cantidad: ${item.name}`}
value={item.qty}
onChange={(e) => cart.setQty(item.id, Number(e.target.value))}
>
{upTo(item.stock).map((n) => (
<option key={n}>{n}</option>
))}
</select>
<span>{money(item.price * item.qty)}</span>
<button type="button" aria-label={`Quitar ${item.name}`} onClick={() => cart.remove(item.id)}>
Quitar
</button>
</li>
))}
</ul>
<p className="price">
Total: {money(cart.total)}
</p>
<Link to="/checkout">Hacer el pedido</Link>
</>
);
}
pages/CheckoutPage.jsximport { useState } from 'react';
import { Link } from 'react-router';
import { placeOrder } from '../api.js';
import Field from '../components/Field.jsx';
import { money } from '../format.js';
import { useCart } from '../store/cart.jsx';
function check(form) {
const errors = {};
if (!form.name.trim()) errors.name = 'Escribe tu nombre.';
if (!form.email.includes('@')) errors.email = 'Escribe tu correo electrónico.';
if (!form.address.trim()) errors.address = 'Escribe tu dirección.';
return errors;
}
export default function CheckoutPage() {
const cart = useCart();
const [form, setForm] = useState({ name: '', email: '', address: '' });
const [errors, setErrors] = useState({});
const [sending, setSending] = useState(false);
const [order, setOrder] = useState(null);
const change = (e) => setForm({ ...form, [e.target.name]: e.target.value });
async function submit(e) {
e.preventDefault();
const found = check(form);
setErrors(found);
if (Object.keys(found).length > 0) return;
setSending(true);
try {
const items = cart.items.map((i) => ({ productId: i.id, qty: i.qty }));
setOrder(await placeOrder({ ...form, items }));
cart.clear();
} catch (err) {
setErrors(err.errors ?? { form: err.message });
} finally {
setSending(false);
}
}
if (order)
return (
<>
<h1>Pedido recibido</h1>
<p role="status">
Pedido n.º {order.id} por {money(order.total)}.
</p>
<p>
<Link to="/orders">Mis pedidos</Link>
</p>
</>
);
if (cart.items.length === 0)
return (
<>
<h1>Datos del pedido</h1>
<p>El carrito está vacío.</p>
</>
);
return (
<>
<h1>Datos del pedido</h1>
<p className="price">
Total: {money(cart.total)}
</p>
<form noValidate onSubmit={submit}>
<Field name="name" label="Nombre" value={form.name} error={errors.name} onChange={change} />
<Field name="email" label="Correo electrónico" type="email" value={form.email} error={errors.email} onChange={change} />
<Field name="address" label="Dirección" value={form.address} error={errors.address} onChange={change} />
{errors.items && <p role="alert">{errors.items}</p>}
{errors.form && <p role="alert">{errors.form}</p>}
<button disabled={sending}>{sending ? 'Enviando…' : 'Confirmar pedido'}</button>
</form>
</>
);
}
pages/OrdersPage.jsxexport default function OrdersPage() {
return (
<>
<h1>Mis pedidos</h1>
<p>Aquí van tus pedidos.</p>
</>
);
}
pages/NotFoundPage.jsximport { Link } from 'react-router';
export default function NotFoundPage() {
return (
<>
<h1>Esa página no existe</h1>
<p>
<Link to="/">Volver al catálogo</Link>
</p>
</>
);
}
components/ProductCard.jsximport { Link } from 'react-router';
import { money } from '../format.js';
export default function ProductCard({ product }) {
return (
<li className="card">
<h2>
<Link to={`/product/${product.id}`}>{product.name}</Link>
</h2>
{product.category && <p className="muted">{product.category}</p>}
<p className="price">{money(product.price)}</p>
</li>
);
}
components/Filter.jsxexport default function Filter({ categories, q, category, onQChange, onCategoryChange }) {
// Los productos están en las subcategorías: «Alimentos» y «Hogar» son solo grupos.
const groups = categories.filter((c) => c.parentId !== null);
return (
<form className="filter" role="search" onSubmit={(e) => e.preventDefault()}>
<label>
Búsqueda
<input type="search" value={q} onChange={(e) => onQChange(e.target.value)} />
</label>
<label>
Categoría
<select value={category} onChange={(e) => onCategoryChange(e.target.value)}>
<option value="">Todas</option>
{groups.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
</form>
);
}
components/Field.jsxexport default function Field({ name, label, type = 'text', value, error, onChange }) {
const errorId = `${name}-error`;
return (
<div className="field">
<label htmlFor={name}>{label}</label>
<input
id={name}
name={name}
type={type}
value={value}
onChange={onChange}
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? errorId : undefined}
/>
{error && (
<span id={errorId} className="error">
{error}
</span>
)}
</div>
);
}
store/cart.jsximport { createContext, useContext, useState } from 'react';
const CartContext = createContext(null);
export function CartProvider({ children }) {
const [items, setItems] = useState([]);
function add(product) {
const found = items.find((i) => i.id === product.id);
if (found) setItems(items.map((i) => (i.id === product.id ? { ...i, qty: i.qty + 1 } : i)));
else setItems([...items, { id: product.id, name: product.name, price: product.price, stock: product.stock, qty: 1 }]);
}
function setQty(id, qty) {
setItems(items.map((i) => (i.id === id ? { ...i, qty } : i)));
}
function remove(id) {
setItems(items.filter((i) => i.id !== id));
}
function clear() {
setItems([]);
}
const count = items.reduce((sum, i) => sum + i.qty, 0);
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
return <CartContext value={{ items, count, total, add, setQty, remove, clear }}>{children}</CartContext>;
}
export function useCart() {
return useContext(CartContext);
}
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.jsximport { createRoot } from 'react-dom/client';
import { MemoryRouter } from 'react-router';
import App from './App.jsx';
import { CartProvider } from './store/cart.jsx';
import './style.css';
// El sandbox no tiene barra de direcciones, así que el historial está en memoria.
// En un proyecto real: BrowserRouter.
createRoot(document.getElementById('root')).render(
<MemoryRouter>
<CartProvider>
<App />
</CartProvider>
</MemoryRouter>,
);
App.jsximport { NavLink, Route, Routes } from 'react-router';
import CartPage from './pages/CartPage.jsx';
import CatalogPage from './pages/CatalogPage.jsx';
import CheckoutPage from './pages/CheckoutPage.jsx';
import NotFoundPage from './pages/NotFoundPage.jsx';
import OrdersPage from './pages/OrdersPage.jsx';
import ProductPage from './pages/ProductPage.jsx';
import { useCart } from './store/cart.jsx';
export default function App() {
const cart = useCart();
return (
<>
<header>
<strong>La tienda</strong>
<nav aria-label="Menú principal">
<NavLink to="/" end>Catálogo</NavLink>
<NavLink to="/cart">Carrito ({cart.count})</NavLink>
<NavLink to="/orders">Mis pedidos</NavLink>
</nav>
</header>
<main>
<Routes>
<Route path="/" element={<CatalogPage />} />
<Route path="/product/:id" element={<ProductPage />} />
<Route path="/cart" element={<CartPage />} />
<Route path="/checkout" element={<CheckoutPage />} />
<Route path="/orders" element={<OrdersPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</main>
</>
);
}
pages/CatalogPage.jsximport { useEffect, useState } from 'react';
import { getCategories, getProducts } from '../api.js';
import Filter from '../components/Filter.jsx';
import ProductCard from '../components/ProductCard.jsx';
export default function CatalogPage() {
const [q, setQ] = useState('');
const [category, setCategory] = useState('');
const [categories, setCategories] = useState([]);
const [products, setProducts] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
getCategories()
.then(setCategories)
.catch(() => setCategories([]));
}, []);
useEffect(() => {
const controller = new AbortController();
setProducts(null);
setError(null);
getProducts({ q, category }, controller.signal)
.then(setProducts)
.catch((e) => {
if (e.name !== 'AbortError') setError(e.message);
});
return () => controller.abort();
}, [q, category]);
return (
<>
<h1>Catálogo</h1>
<Filter categories={categories} q={q} category={category} onQChange={setQ} onCategoryChange={setCategory} />
{error && <p role="alert">{error}</p>}
{!error && !products && <p role="status">Cargando…</p>}
{products && products.length === 0 && <p>No hay productos que coincidan.</p>}
{products && products.length > 0 && (
<ul className="grid">
{products.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</ul>
)}
</>
);
}
pages/ProductPage.jsximport { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router';
import { getProduct, getProducts } from '../api.js';
import { money } from '../format.js';
import { useCart } from '../store/cart.jsx';
export default function ProductPage() {
const { id } = useParams();
const cart = useCart();
const [product, setProduct] = useState(null);
const [related, setRelated] = useState([]);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
async function load() {
const p = await getProduct(id, controller.signal);
setProduct(p);
if (p.categoryId === null) return;
const same = await getProducts({ category: p.categoryId }, controller.signal);
setRelated(same.filter((x) => x.id !== p.id).slice(0, 3));
}
setProduct(null);
setRelated([]);
setError(null);
load().catch((e) => {
if (e.name !== 'AbortError') setError(e.status === 404 ? 'No existe ese producto.' : e.message);
});
return () => controller.abort();
}, [id]);
if (error) return <p role="alert">{error}</p>;
if (!product) return <p role="status">Cargando…</p>;
const inCart = cart.items.find((i) => i.id === product.id)?.qty ?? 0;
return (
<article>
<p>
<Link to="/">← Volver al catálogo</Link>
</p>
<h1>{product.name}</h1>
{product.category && <p className="muted">{product.category}</p>}
<p className="price">{money(product.price)}</p>
<p>{product.stock > 0 ? `En existencias: ${product.stock} uds.` : 'Agotado'}</p>
<button type="button" disabled={product.stock === 0} onClick={() => cart.add(product)}>
Añadir al carrito
</button>
{inCart > 0 && <p role="status">En el carrito: {inCart}</p>}
{related.length > 0 && (
<section aria-labelledby="related-title">
<h2 id="related-title">Más de esta categoría</h2>
<ul>
{related.map((p) => (
<li key={p.id}>
<Link to={`/product/${p.id}`}>{p.name}</Link>
</li>
))}
</ul>
</section>
)}
</article>
);
}
pages/CartPage.jsximport { Link } from 'react-router';
import { money } from '../format.js';
import { useCart } from '../store/cart.jsx';
const upTo = (n) => Array.from({ length: Math.min(n, 10) }, (_, i) => i + 1);
export default function CartPage() {
const cart = useCart();
if (cart.items.length === 0)
return (
<>
<h1>Carrito</h1>
<p>El carrito está vacío.</p>
<p>
<Link to="/">Volver al catálogo</Link>
</p>
</>
);
return (
<>
<h1>Carrito</h1>
<ul className="cart">
{cart.items.map((item) => (
<li key={item.id}>
<span>{item.name}</span>
<select
aria-label={`Cantidad: ${item.name}`}
value={item.qty}
onChange={(e) => cart.setQty(item.id, Number(e.target.value))}
>
{upTo(item.stock).map((n) => (
<option key={n}>{n}</option>
))}
</select>
<span>{money(item.price * item.qty)}</span>
<button type="button" aria-label={`Quitar ${item.name}`} onClick={() => cart.remove(item.id)}>
Quitar
</button>
</li>
))}
</ul>
<p className="price">
Total: {money(cart.total)}
</p>
<Link to="/checkout">Hacer el pedido</Link>
</>
);
}
pages/CheckoutPage.jsximport { useState } from 'react';
import { Link } from 'react-router';
import { placeOrder } from '../api.js';
import Field from '../components/Field.jsx';
import { money } from '../format.js';
import { useCart } from '../store/cart.jsx';
function check(form) {
const errors = {};
if (!form.name.trim()) errors.name = 'Escribe tu nombre.';
if (!form.email.includes('@')) errors.email = 'Escribe tu correo electrónico.';
if (!form.address.trim()) errors.address = 'Escribe tu dirección.';
return errors;
}
export default function CheckoutPage() {
const cart = useCart();
const [form, setForm] = useState({ name: '', email: '', address: '' });
const [errors, setErrors] = useState({});
const [sending, setSending] = useState(false);
const [order, setOrder] = useState(null);
const change = (e) => setForm({ ...form, [e.target.name]: e.target.value });
async function submit(e) {
e.preventDefault();
const found = check(form);
setErrors(found);
if (Object.keys(found).length > 0) return;
setSending(true);
try {
const items = cart.items.map((i) => ({ productId: i.id, qty: i.qty }));
setOrder(await placeOrder({ ...form, items }));
cart.clear();
} catch (err) {
setErrors(err.errors ?? { form: err.message });
} finally {
setSending(false);
}
}
if (order)
return (
<>
<h1>Pedido recibido</h1>
<p role="status">
Pedido n.º {order.id} por {money(order.total)}.
</p>
<p>
<Link to="/orders">Mis pedidos</Link>
</p>
</>
);
if (cart.items.length === 0)
return (
<>
<h1>Datos del pedido</h1>
<p>El carrito está vacío.</p>
</>
);
return (
<>
<h1>Datos del pedido</h1>
<p className="price">
Total: {money(cart.total)}
</p>
<form noValidate onSubmit={submit}>
<Field name="name" label="Nombre" value={form.name} error={errors.name} onChange={change} />
<Field name="email" label="Correo electrónico" type="email" value={form.email} error={errors.email} onChange={change} />
<Field name="address" label="Dirección" value={form.address} error={errors.address} onChange={change} />
{errors.items && <p role="alert">{errors.items}</p>}
{errors.form && <p role="alert">{errors.form}</p>}
<button disabled={sending}>{sending ? 'Enviando…' : 'Confirmar pedido'}</button>
</form>
</>
);
}
pages/OrdersPage.jsximport { useEffect, useState } from 'react';
import { ME, getOrders } from '../api.js';
import { date, money, statusText } from '../format.js';
export default function OrdersPage() {
const [orders, setOrders] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
getOrders(controller.signal)
.then((all) => setOrders(all.filter((o) => o.customerId === ME).sort((a, b) => b.id - a.id)))
.catch((e) => {
if (e.name !== 'AbortError') setError(e.message);
});
return () => controller.abort();
}, []);
return (
<>
<h1 id="orders-title">Mis pedidos</h1>
{error && <p role="alert">{error}</p>}
{!error && !orders && <p role="status">Cargando…</p>}
{orders && orders.length === 0 && <p>Todavía no tienes pedidos.</p>}
{orders && orders.length > 0 && (
<ul className="orders" aria-labelledby="orders-title">
{orders.map((o) => (
<li key={o.id}>
<h2>Pedido n.º {o.id}</h2>
<p className="muted">
{date(o.date)} · {statusText(o.status)} · {money(o.total)}
</p>
<p>{o.items.map((i) => `${i.name} × ${i.qty}`).join(', ')}</p>
</li>
))}
</ul>
)}
</>
);
}
pages/NotFoundPage.jsximport { Link } from 'react-router';
export default function NotFoundPage() {
return (
<>
<h1>Esa página no existe</h1>
<p>
<Link to="/">Volver al catálogo</Link>
</p>
</>
);
}
components/ProductCard.jsximport { Link } from 'react-router';
import { money } from '../format.js';
export default function ProductCard({ product }) {
return (
<li className="card">
<h2>
<Link to={`/product/${product.id}`}>{product.name}</Link>
</h2>
{product.category && <p className="muted">{product.category}</p>}
<p className="price">{money(product.price)}</p>
</li>
);
}
components/Filter.jsxexport default function Filter({ categories, q, category, onQChange, onCategoryChange }) {
// Los productos están en las subcategorías: «Alimentos» y «Hogar» son solo grupos.
const groups = categories.filter((c) => c.parentId !== null);
return (
<form className="filter" role="search" onSubmit={(e) => e.preventDefault()}>
<label>
Búsqueda
<input type="search" value={q} onChange={(e) => onQChange(e.target.value)} />
</label>
<label>
Categoría
<select value={category} onChange={(e) => onCategoryChange(e.target.value)}>
<option value="">Todas</option>
{groups.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
</form>
);
}
components/Field.jsxexport default function Field({ name, label, type = 'text', value, error, onChange }) {
const errorId = `${name}-error`;
return (
<div className="field">
<label htmlFor={name}>{label}</label>
<input
id={name}
name={name}
type={type}
value={value}
onChange={onChange}
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? errorId : undefined}
/>
{error && (
<span id={errorId} className="error">
{error}
</span>
)}
</div>
);
}
store/cart.jsximport { createContext, useContext, useState } from 'react';
const CartContext = createContext(null);
export function CartProvider({ children }) {
const [items, setItems] = useState([]);
function add(product) {
const found = items.find((i) => i.id === product.id);
if (found) setItems(items.map((i) => (i.id === product.id ? { ...i, qty: i.qty + 1 } : i)));
else setItems([...items, { id: product.id, name: product.name, price: product.price, stock: product.stock, qty: 1 }]);
}
function setQty(id, qty) {
setItems(items.map((i) => (i.id === id ? { ...i, qty } : i)));
}
function remove(id) {
setItems(items.filter((i) => i.id !== id));
}
function clear() {
setItems([]);
}
const count = items.reduce((sum, i) => sum + i.qty, 0);
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
return <CartContext value={{ items, count, total, add, setQty, remove, clear }}>{children}</CartContext>;
}
export function useCart() {
return useContext(CartContext);
}
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.
BrowserRouteren 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. React lo hace con hooks: useEffect con dependencias, Context para el carrito, campos controlados con value y onChange. 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 reactcd shopnpm install react-routerCopia las carpetas pages/, components/, store/ y los archivos api.js, format.js, style.css a src/. En main.jsx cambia MemoryRouter por BrowserRouter.
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