Tema
Proyecto: e-commerce
El proyecto final del curso: el frontend completo de una tienda en línea. Catálogo con filtros, página de producto, carrito persistente con Zustand, checkout con validación y confirmación de pedido. Integra todo lo aprendido y es una pieza fuerte para tu portafolio o la base de la tienda de un cliente.
Qué vas a construir
Tiendita, con:
- Inicio: banner y productos destacados.
- Catálogo: grilla de productos con búsqueda, categorías y orden (estado en la URL), y "cargar más".
- Producto: galería, precio con descuento, selector de cantidad y "Agregar al carrito".
- Carrito lateral (drawer) y página de carrito: cambiar cantidades, quitar, subtotal, envío e impuestos.
- Checkout: formulario validado con React Hook Form + zod y un pedido simulado.
- Confirmación con número de pedido.
- Carrito guardado en
localStoragey notificaciones (toasts).
Tecnologías: React 19, Vite, React Router v7, TanStack Query, Zustand, Tailwind CSS v4, React Hook Form + zod, con la API de DummyJSON.
Crear el proyecto
bash
npm create vite@latest tiendita -- --template react
cd tiendita
npm install
npm i react-router @tanstack/react-query zustand react-hook-form zod @hookform/resolvers
npm i tailwindcss @tailwindcss/viteEstructura
txt
src/
├── api/productos.js
├── features/
│ ├── carrito/
│ │ ├── store.js
│ │ ├── CarritoDrawer.jsx
│ │ └── ItemCarrito.jsx
│ ├── productos/
│ │ ├── TarjetaProducto.jsx
│ │ └── Precio.jsx
│ └── toasts/
│ ├── store.js
│ └── Toasts.jsx
├── layouts/TiendaLayout.jsx
├── pages/
│ ├── Inicio.jsx
│ ├── Catalogo.jsx
│ ├── Producto.jsx
│ ├── Carrito.jsx
│ ├── Checkout.jsx
│ └── Confirmacion.jsx
├── lib/formato.js
├── router.jsx
├── index.css
└── main.jsxPaso 1: configuración
js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()]
})css
@import "tailwindcss";
@theme {
--color-marca-50: #f5f3ff;
--color-marca-100: #ede9fe;
--color-marca-600: #7c3aed;
--color-marca-700: #6d28d9;
--color-marca-900: #4c1d95;
}
body {
@apply bg-gray-50 text-gray-900 antialiased;
}jsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { RouterProvider } from 'react-router'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { router } from './router'
import './index.css'
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 5 * 60_000, retry: 1 } }
})
createRoot(document.getElementById('root')).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</StrictMode>
)jsx
import { createBrowserRouter } from 'react-router'
import TiendaLayout from './layouts/TiendaLayout'
import Inicio from './pages/Inicio'
import Catalogo from './pages/Catalogo'
import Producto from './pages/Producto'
import Carrito from './pages/Carrito'
import Checkout from './pages/Checkout'
import Confirmacion from './pages/Confirmacion'
export const router = createBrowserRouter([
{
path: '/',
element: <TiendaLayout />,
children: [
{ index: true, element: <Inicio /> },
{ path: 'catalogo', element: <Catalogo /> },
{ path: 'producto/:id', element: <Producto /> },
{ path: 'carrito', element: <Carrito /> },
{ path: 'checkout', element: <Checkout /> },
{ path: 'pedido/:folio', element: <Confirmacion /> },
{ path: '*', element: <p className="p-8 text-center">Página no encontrada</p> }
]
}
])js
// DummyJSON da precios en USD; los convertimos a MXN con un tipo de cambio fijo de ejemplo
const TIPO_CAMBIO = 18
export const aMXN = (usd) => Math.round(usd * TIPO_CAMBIO)
export const moneda = (n) =>
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(n)
export const precioFinal = (producto) =>
aMXN(producto.price * (1 - (producto.discountPercentage ?? 0) / 100))Paso 2: API y stores
js
import { infiniteQueryOptions, queryOptions } from '@tanstack/react-query'
const BASE = 'https://dummyjson.com'
const CAMPOS = 'title,price,discountPercentage,rating,stock,thumbnail,category,brand'
async function get(ruta, signal) {
const res = await fetch(`${BASE}${ruta}`, { signal })
if (!res.ok) throw new Error(res.status === 404 ? 'Producto no encontrado' : 'Error al cargar')
return res.json()
}
export const categoriasQuery = queryOptions({
queryKey: ['categorias'],
queryFn: ({ signal }) => get('/products/categories', signal), // [{ slug, name }]
staleTime: Infinity
})
export const destacadosQuery = queryOptions({
queryKey: ['destacados'],
queryFn: ({ signal }) => get(`/products?limit=8&sortBy=rating&order=desc&select=${CAMPOS}`, signal)
})
export const productoQuery = (id) =>
queryOptions({
queryKey: ['producto', id],
queryFn: ({ signal }) => get(`/products/${id}`, signal)
})
// Consulta infinita para "Cargar más"
export const catalogoQuery = ({ q, categoria, orden }) =>
infiniteQueryOptions({
queryKey: ['catalogo', { q, categoria, orden }],
initialPageParam: 0,
queryFn: ({ pageParam, signal }) => {
const p = new URLSearchParams({ limit: '12', skip: String(pageParam), select: CAMPOS })
if (orden) {
const [campo, dir] = orden.split(':')
p.set('sortBy', campo)
p.set('order', dir)
}
if (q) return get(`/products/search?q=${encodeURIComponent(q)}&${p}`, signal)
if (categoria) return get(`/products/category/${categoria}?${p}`, signal)
return get(`/products?${p}`, signal)
},
// Siguiente skip, o undefined si ya no hay más
getNextPageParam: (ultima) => {
const siguiente = ultima.skip + ultima.limit
return siguiente < ultima.total ? siguiente : undefined
}
})js
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { precioFinal } from '../../lib/formato'
export const ENVIO_GRATIS_DESDE = 999
export const COSTO_ENVIO = 149
export const useCarrito = create(
persist(
(set) => ({
items: [], // { id, titulo, imagen, precio (MXN), stock, cantidad }
abierto: false,
agregar: (producto, cantidad = 1) =>
set((s) => {
const existe = s.items.find((i) => i.id === producto.id)
const items = existe
? s.items.map((i) =>
i.id === producto.id ? { ...i, cantidad: Math.min(i.cantidad + cantidad, i.stock) } : i
)
: [
...s.items,
{
id: producto.id,
titulo: producto.title,
imagen: producto.thumbnail,
precio: precioFinal(producto),
stock: producto.stock,
cantidad: Math.min(cantidad, producto.stock)
}
]
return { items, abierto: true }
}),
cambiarCantidad: (id, cantidad) =>
set((s) => ({
items:
cantidad < 1
? s.items.filter((i) => i.id !== id)
: s.items.map((i) => (i.id === id ? { ...i, cantidad: Math.min(cantidad, i.stock) } : i))
})),
quitar: (id) => set((s) => ({ items: s.items.filter((i) => i.id !== id) })),
vaciar: () => set({ items: [] }),
abrir: () => set({ abierto: true }),
cerrar: () => set({ abierto: false })
}),
{
name: 'tiendita-carrito',
partialize: (s) => ({ items: s.items }) // no persistimos si el drawer está abierto
}
)
)
// Selectores
export const selectPiezas = (s) => s.items.reduce((n, i) => n + i.cantidad, 0)
export function calcularTotales(items) {
const subtotal = items.reduce((t, i) => t + i.precio * i.cantidad, 0)
const envio = subtotal === 0 || subtotal >= ENVIO_GRATIS_DESDE ? 0 : COSTO_ENVIO
const iva = Math.round((subtotal / 1.16) * 0.16) // los precios ya incluyen IVA: lo desglosamos
return { subtotal, envio, iva, total: subtotal + envio }
}js
import { create } from 'zustand'
export const useToasts = create((set) => ({
toasts: [],
mostrar: (mensaje, tipo = 'exito') => {
const id = crypto.randomUUID()
set((s) => ({ toasts: [...s.toasts, { id, mensaje, tipo }] }))
setTimeout(() => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })), 3000)
}
}))Paso 3: componentes
jsx
import { aMXN, moneda, precioFinal } from '../../lib/formato'
export default function Precio({ producto, grande = false }) {
const final = precioFinal(producto)
const original = aMXN(producto.price)
const tieneDescuento = producto.discountPercentage >= 1
return (
<div className="flex flex-wrap items-baseline gap-2">
<span className={`${grande ? 'text-3xl' : 'text-lg'} font-bold text-gray-900`}>{moneda(final)}</span>
{tieneDescuento && (
<>
<s className="text-sm text-gray-400">{moneda(original)}</s>
<span className="rounded bg-green-100 px-1.5 text-xs font-semibold text-green-700">
-{Math.round(producto.discountPercentage)}%
</span>
</>
)}
</div>
)
}jsx
import { Link } from 'react-router'
import { useCarrito } from '../carrito/store'
import { useToasts } from '../toasts/store'
import Precio from './Precio'
export default function TarjetaProducto({ producto }) {
const agregar = useCarrito((s) => s.agregar)
const mostrar = useToasts((s) => s.mostrar)
const agotado = producto.stock === 0
return (
<article className="group flex flex-col overflow-hidden rounded-2xl border border-gray-200 bg-white transition hover:shadow-lg">
<Link to={`/producto/${producto.id}`} className="relative block aspect-square bg-gray-100">
<img
src={producto.thumbnail}
alt={producto.title}
loading="lazy"
className="h-full w-full object-contain p-4 transition duration-300 group-hover:scale-105"
/>
</Link>
<div className="flex flex-1 flex-col gap-2 p-4">
<p className="text-xs uppercase tracking-wide text-gray-500">{producto.brand ?? producto.category}</p>
<Link to={`/producto/${producto.id}`} className="line-clamp-2 font-medium hover:text-marca-700">
{producto.title}
</Link>
<p className="text-sm text-amber-600">★ {producto.rating.toFixed(1)}</p>
<Precio producto={producto} />
<button
disabled={agotado}
onClick={() => {
agregar(producto)
mostrar(`${producto.title} agregado al carrito`)
}}
className="mt-auto rounded-lg bg-marca-600 py-2 font-medium text-white transition hover:bg-marca-700 active:scale-95 disabled:bg-gray-300"
>
{agotado ? 'Agotado' : 'Agregar al carrito'}
</button>
</div>
</article>
)
}jsx
import { useCarrito } from './store'
import { moneda } from '../../lib/formato'
export default function ItemCarrito({ item }) {
const cambiarCantidad = useCarrito((s) => s.cambiarCantidad)
const quitar = useCarrito((s) => s.quitar)
return (
<li className="flex gap-3 py-4">
<img src={item.imagen} alt="" className="h-20 w-20 rounded-lg bg-gray-100 object-contain p-1" />
<div className="flex flex-1 flex-col">
<p className="line-clamp-2 text-sm font-medium">{item.titulo}</p>
<p className="text-sm text-gray-500">{moneda(item.precio)} c/u</p>
<div className="mt-auto flex items-center gap-2">
<div className="flex items-center rounded-lg border">
<button className="px-2 py-1" onClick={() => cambiarCantidad(item.id, item.cantidad - 1)} aria-label="Quitar uno">−</button>
<span className="w-8 text-center text-sm" aria-live="polite">{item.cantidad}</span>
<button
className="px-2 py-1 disabled:opacity-30"
onClick={() => cambiarCantidad(item.id, item.cantidad + 1)}
disabled={item.cantidad >= item.stock}
aria-label="Agregar uno"
>
+
</button>
</div>
<button className="text-xs text-red-600 hover:underline" onClick={() => quitar(item.id)}>Quitar</button>
</div>
</div>
<p className="font-semibold">{moneda(item.precio * item.cantidad)}</p>
</li>
)
}jsx
import { useEffect } from 'react'
import { Link } from 'react-router'
import { useCarrito, calcularTotales, ENVIO_GRATIS_DESDE } from './store'
import ItemCarrito from './ItemCarrito'
import { moneda } from '../../lib/formato'
export default function CarritoDrawer() {
const items = useCarrito((s) => s.items)
const abierto = useCarrito((s) => s.abierto)
const cerrar = useCarrito((s) => s.cerrar)
const { subtotal } = calcularTotales(items)
const faltante = ENVIO_GRATIS_DESDE - subtotal
// Cerrar con Escape y bloquear el scroll del fondo
useEffect(() => {
if (!abierto) return
const tecla = (e) => e.key === 'Escape' && cerrar()
document.addEventListener('keydown', tecla)
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', tecla)
document.body.style.overflow = ''
}
}, [abierto, cerrar])
if (!abierto) return null
return (
<div className="fixed inset-0 z-50">
<div className="absolute inset-0 bg-black/40" onClick={cerrar} />
<aside
role="dialog"
aria-modal="true"
aria-label="Carrito de compras"
className="absolute right-0 top-0 flex h-full w-full max-w-md flex-col bg-white shadow-2xl"
>
<header className="flex items-center justify-between border-b p-4">
<h2 className="text-lg font-bold">Tu carrito</h2>
<button onClick={cerrar} aria-label="Cerrar carrito" className="text-2xl">×</button>
</header>
{items.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-8 text-center">
<p className="text-5xl">🛒</p>
<p>Tu carrito está vacío</p>
<Link to="/catalogo" onClick={cerrar} className="text-marca-700 underline">Ver productos</Link>
</div>
) : (
<>
{faltante > 0 && (
<p className="bg-marca-50 px-4 py-2 text-sm text-marca-900">
Te faltan <strong>{moneda(faltante)}</strong> para envío gratis 🚚
</p>
)}
<ul className="flex-1 divide-y overflow-y-auto px-4">
{items.map((i) => <ItemCarrito key={i.id} item={i} />)}
</ul>
<footer className="space-y-3 border-t p-4">
<div className="flex justify-between font-semibold">
<span>Subtotal</span>
<span>{moneda(subtotal)}</span>
</div>
<Link to="/checkout" onClick={cerrar} className="block rounded-lg bg-marca-600 py-3 text-center font-semibold text-white hover:bg-marca-700">
Ir a pagar
</Link>
<Link to="/carrito" onClick={cerrar} className="block text-center text-sm text-gray-600 underline">
Ver carrito completo
</Link>
</footer>
</>
)}
</aside>
</div>
)
}jsx
import { useToasts } from './store'
export default function Toasts() {
const toasts = useToasts((s) => s.toasts)
return (
<div aria-live="polite" className="fixed bottom-4 left-1/2 z-[60] flex -translate-x-1/2 flex-col gap-2">
{toasts.map((t) => (
<div key={t.id} className="rounded-lg bg-gray-900 px-4 py-3 text-sm text-white shadow-lg">
{t.tipo === 'exito' ? '✅' : 'ℹ️'} {t.mensaje}
</div>
))}
</div>
)
}jsx
import { Link, NavLink, Outlet, ScrollRestoration } from 'react-router'
import { useCarrito, selectPiezas } from '../features/carrito/store'
import CarritoDrawer from '../features/carrito/CarritoDrawer'
import Toasts from '../features/toasts/Toasts'
export default function TiendaLayout() {
const piezas = useCarrito(selectPiezas)
const abrir = useCarrito((s) => s.abrir)
const enlace = ({ isActive }) => (isActive ? 'font-semibold text-marca-700' : 'text-gray-600 hover:text-gray-900')
return (
<div className="flex min-h-screen flex-col">
<header className="sticky top-0 z-40 border-b bg-white/90 backdrop-blur">
<div className="mx-auto flex max-w-6xl items-center gap-6 px-4 py-3">
<Link to="/" className="text-xl font-extrabold text-marca-700">🛍️ Tiendita</Link>
<nav className="flex gap-4 text-sm">
<NavLink to="/" end className={enlace}>Inicio</NavLink>
<NavLink to="/catalogo" className={enlace}>Catálogo</NavLink>
</nav>
<button
onClick={abrir}
className="relative ml-auto rounded-full p-2 hover:bg-gray-100"
aria-label={`Abrir carrito, ${piezas} productos`}
>
🛒
{piezas > 0 && (
<span className="absolute -right-1 -top-1 grid h-5 min-w-5 place-items-center rounded-full bg-marca-600 px-1 text-xs font-bold text-white">
{piezas}
</span>
)}
</button>
</div>
</header>
<main className="mx-auto w-full max-w-6xl flex-1 px-4 py-8">
<Outlet />
</main>
<footer className="border-t bg-white py-6 text-center text-sm text-gray-500">
© {new Date().getFullYear()} Tiendita · Proyecto de práctica
</footer>
<CarritoDrawer />
<Toasts />
<ScrollRestoration />
</div>
)
}Paso 4: páginas
jsx
import { Link } from 'react-router'
import { useQuery } from '@tanstack/react-query'
import { destacadosQuery } from '../api/productos'
import TarjetaProducto from '../features/productos/TarjetaProducto'
export default function Inicio() {
const { data, isPending, isError } = useQuery(destacadosQuery)
return (
<>
<title>Tiendita | Inicio</title>
<section className="mb-10 rounded-3xl bg-gradient-to-br from-marca-700 to-marca-900 px-6 py-14 text-white sm:px-12">
<p className="mb-2 text-sm uppercase tracking-widest text-marca-100">Temporada 2026</p>
<h1 className="max-w-xl text-3xl font-extrabold sm:text-5xl">Todo lo que necesitas, en un solo lugar</h1>
<p className="mt-4 max-w-md text-marca-100">Envío gratis en compras mayores a $999.</p>
<Link to="/catalogo" className="mt-6 inline-block rounded-full bg-white px-6 py-3 font-semibold text-marca-900 hover:bg-marca-50">
Ver catálogo →
</Link>
</section>
<h2 className="mb-4 text-2xl font-bold">Mejor calificados</h2>
{isPending && <p>Cargando…</p>}
{isError && <p>No se pudieron cargar los productos.</p>}
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
{data?.products.map((p) => <TarjetaProducto key={p.id} producto={p} />)}
</div>
</>
)
}jsx
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router'
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
import { catalogoQuery, categoriasQuery } from '../api/productos'
import TarjetaProducto from '../features/productos/TarjetaProducto'
export default function Catalogo() {
const [params, setParams] = useSearchParams()
const categoria = params.get('categoria') ?? ''
const orden = params.get('orden') ?? ''
const q = params.get('q') ?? ''
const [texto, setTexto] = useState(q)
function actualizar(cambios) {
const nuevos = new URLSearchParams(params)
Object.entries(cambios).forEach(([k, v]) => (v ? nuevos.set(k, v) : nuevos.delete(k)))
setParams(nuevos, { replace: true })
}
// Debounce de la búsqueda hacia la URL
useEffect(() => {
const id = setTimeout(() => {
if (texto.trim() !== q) actualizar({ q: texto.trim(), categoria: '' })
}, 400)
return () => clearTimeout(id)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [texto])
const categorias = useQuery(categoriasQuery)
const { data, isPending, isError, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery(
catalogoQuery({ q, categoria, orden })
)
const productos = data?.pages.flatMap((p) => p.products) ?? []
const total = data?.pages[0]?.total ?? 0
return (
<>
<title>Catálogo | Tiendita</title>
<h1 className="mb-6 text-3xl font-bold">Catálogo</h1>
<div className="mb-6 flex flex-wrap gap-3">
<input
type="search"
value={texto}
onChange={(e) => setTexto(e.target.value)}
placeholder="Buscar productos…"
aria-label="Buscar productos"
className="flex-1 rounded-lg border px-4 py-2"
/>
<select
value={categoria}
onChange={(e) => { setTexto(''); actualizar({ categoria: e.target.value, q: '' }) }}
aria-label="Categoría"
className="rounded-lg border px-3 py-2"
>
<option value="">Todas las categorías</option>
{categorias.data?.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}
</select>
<select value={orden} onChange={(e) => actualizar({ orden: e.target.value })} aria-label="Ordenar" className="rounded-lg border px-3 py-2">
<option value="">Relevancia</option>
<option value="price:asc">Menor precio</option>
<option value="price:desc">Mayor precio</option>
<option value="rating:desc">Mejor calificados</option>
</select>
</div>
{isPending && <p>Cargando productos…</p>}
{isError && <p>Ocurrió un error al cargar el catálogo.</p>}
{!isPending && productos.length === 0 && <p>No encontramos productos{q && ` para "${q}"`}.</p>}
{total > 0 && <p className="mb-3 text-sm text-gray-500">{total} productos</p>}
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
{productos.map((p) => <TarjetaProducto key={p.id} producto={p} />)}
</div>
{hasNextPage && (
<div className="mt-8 text-center">
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
className="rounded-full border border-marca-600 px-6 py-2 font-medium text-marca-700 hover:bg-marca-50 disabled:opacity-50"
>
{isFetchingNextPage ? 'Cargando…' : 'Cargar más'}
</button>
</div>
)}
</>
)
}jsx
import { useState } from 'react'
import { Link, useParams } from 'react-router'
import { useQuery } from '@tanstack/react-query'
import { productoQuery } from '../api/productos'
import { useCarrito } from '../features/carrito/store'
import Precio from '../features/productos/Precio'
export default function Producto() {
const { id } = useParams()
const { data: p, isPending, isError, error } = useQuery(productoQuery(id))
const agregar = useCarrito((s) => s.agregar)
const [cantidad, setCantidad] = useState(1)
const [imagen, setImagen] = useState(0)
if (isPending) return <div className="h-96 animate-pulse rounded-2xl bg-gray-200" />
if (isError) return <p>{error.message}. <Link to="/catalogo" className="underline">Volver al catálogo</Link></p>
return (
<>
<title>{`${p.title} | Tiendita`}</title>
<meta name="description" content={p.description} />
<nav className="mb-4 text-sm text-gray-500">
<Link to="/catalogo" className="hover:underline">Catálogo</Link> /{' '}
<Link to={`/catalogo?categoria=${p.category}`} className="capitalize hover:underline">{p.category}</Link>
</nav>
<div className="grid gap-8 md:grid-cols-2">
<section>
<div className="aspect-square overflow-hidden rounded-2xl bg-white">
<img src={p.images[imagen]} alt={p.title} className="h-full w-full object-contain p-6" />
</div>
<div className="mt-3 flex gap-2 overflow-x-auto">
{p.images.map((src, i) => (
<button
key={src}
onClick={() => setImagen(i)}
aria-label={`Imagen ${i + 1}`}
className={`h-16 w-16 shrink-0 rounded-lg border-2 bg-white ${i === imagen ? 'border-marca-600' : 'border-transparent'}`}
>
<img src={src} alt="" className="h-full w-full object-contain" />
</button>
))}
</div>
</section>
<section className="space-y-4">
<p className="text-sm uppercase tracking-wide text-gray-500">{p.brand}</p>
<h1 className="text-3xl font-bold">{p.title}</h1>
<p className="text-amber-600">★ {p.rating.toFixed(1)} · {p.reviews.length} reseñas</p>
<Precio producto={p} grande />
<p className="text-gray-700">{p.description}</p>
<p className={`text-sm ${p.stock < 10 ? 'text-red-600' : 'text-green-700'}`}>
{p.stock === 0 ? 'Agotado' : p.stock < 10 ? `¡Sólo quedan ${p.stock}!` : 'En existencia'}
</p>
<div className="flex items-center gap-3">
<label htmlFor="cantidad" className="text-sm">Cantidad</label>
<select
id="cantidad"
value={cantidad}
onChange={(e) => setCantidad(Number(e.target.value))}
className="rounded-lg border px-3 py-2"
>
{Array.from({ length: Math.min(p.stock, 10) }, (_, i) => i + 1).map((n) => (
<option key={n} value={n}>{n}</option>
))}
</select>
</div>
<button
disabled={p.stock === 0}
onClick={() => agregar(p, cantidad)}
className="w-full rounded-xl bg-marca-600 py-3 text-lg font-semibold text-white hover:bg-marca-700 disabled:bg-gray-300"
>
Agregar al carrito
</button>
<ul className="space-y-1 text-sm text-gray-600">
<li>🚚 {p.shippingInformation}</li>
<li>🛡️ {p.warrantyInformation}</li>
<li>↩️ {p.returnPolicy}</li>
</ul>
</section>
</div>
</>
)
}jsx
import { Link } from 'react-router'
import { useCarrito, calcularTotales } from '../features/carrito/store'
import ItemCarrito from '../features/carrito/ItemCarrito'
import { moneda } from '../lib/formato'
export function ResumenTotales({ items }) {
const { subtotal, envio, iva, total } = calcularTotales(items)
return (
<dl className="space-y-2 text-sm">
<div className="flex justify-between"><dt>Subtotal</dt><dd>{moneda(subtotal)}</dd></div>
<div className="flex justify-between"><dt>Envío</dt><dd>{envio === 0 ? 'Gratis' : moneda(envio)}</dd></div>
<div className="flex justify-between text-gray-500"><dt>IVA incluido</dt><dd>{moneda(iva)}</dd></div>
<div className="flex justify-between border-t pt-2 text-lg font-bold"><dt>Total</dt><dd>{moneda(total)}</dd></div>
</dl>
)
}
export default function Carrito() {
const items = useCarrito((s) => s.items)
const vaciar = useCarrito((s) => s.vaciar)
if (items.length === 0) {
return (
<div className="py-16 text-center">
<title>Carrito | Tiendita</title>
<p className="text-6xl">🛒</p>
<h1 className="mt-4 text-2xl font-bold">Tu carrito está vacío</h1>
<Link to="/catalogo" className="mt-4 inline-block text-marca-700 underline">Explorar productos</Link>
</div>
)
}
return (
<div className="grid gap-8 lg:grid-cols-3">
<title>Carrito | Tiendita</title>
<section className="rounded-2xl bg-white p-6 lg:col-span-2">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Carrito</h1>
<button onClick={vaciar} className="text-sm text-red-600 hover:underline">Vaciar carrito</button>
</div>
<ul className="divide-y">{items.map((i) => <ItemCarrito key={i.id} item={i} />)}</ul>
</section>
<aside className="h-fit space-y-4 rounded-2xl bg-white p-6">
<h2 className="text-lg font-bold">Resumen</h2>
<ResumenTotales items={items} />
<Link to="/checkout" className="block rounded-xl bg-marca-600 py-3 text-center font-semibold text-white hover:bg-marca-700">
Continuar al pago
</Link>
</aside>
</div>
)
}jsx
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { Navigate, useNavigate } from 'react-router'
import { useCarrito, calcularTotales } from '../features/carrito/store'
import { ResumenTotales } from './Carrito'
const schema = z.object({
nombre: z.string().trim().min(3, 'Escribe tu nombre completo'),
email: z.email('Email inválido'),
telefono: z.string().regex(/^\d{10}$/, 'Teléfono de 10 dígitos'),
calle: z.string().trim().min(5, 'Escribe calle y número'),
ciudad: z.string().trim().min(2, 'Escribe tu ciudad'),
cp: z.string().regex(/^\d{5}$/, 'Código postal de 5 dígitos'),
pago: z.enum(['tarjeta', 'transferencia', 'oxxo'], { message: 'Elige un método de pago' }),
terminos: z.literal(true, { message: 'Debes aceptar los términos' })
})
// Simula el envío del pedido a tu backend
async function crearPedido(datos) {
await new Promise((r) => setTimeout(r, 1200))
return { folio: `TD-${Date.now().toString().slice(-6)}`, ...datos }
}
function Campo({ etiqueta, error, children }) {
return (
<label className="block text-sm">
<span className="mb-1 block font-medium">{etiqueta}</span>
{children}
{error && <span className="mt-1 block text-red-600" role="alert">{error.message}</span>}
</label>
)
}
const claseInput = 'w-full rounded-lg border px-3 py-2 aria-[invalid=true]:border-red-500'
export default function Checkout() {
const items = useCarrito((s) => s.items)
const vaciar = useCarrito((s) => s.vaciar)
const navigate = useNavigate()
const {
register,
handleSubmit,
formState: { errors, isSubmitting }
} = useForm({ resolver: zodResolver(schema), defaultValues: { pago: 'tarjeta' } })
if (items.length === 0) return <Navigate to="/carrito" replace />
async function onSubmit(datos) {
const pedido = await crearPedido({ ...datos, items, totales: calcularTotales(items) })
vaciar()
navigate(`/pedido/${pedido.folio}`, { replace: true, state: { pedido } })
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate className="grid gap-8 lg:grid-cols-3">
<title>Checkout | Tiendita</title>
<section className="space-y-6 rounded-2xl bg-white p-6 lg:col-span-2">
<h1 className="text-2xl font-bold">Datos de envío</h1>
<div className="grid gap-4 sm:grid-cols-2">
<Campo etiqueta="Nombre completo" error={errors.nombre}>
<input className={claseInput} autoComplete="name" aria-invalid={!!errors.nombre} {...register('nombre')} />
</Campo>
<Campo etiqueta="Email" error={errors.email}>
<input type="email" className={claseInput} autoComplete="email" aria-invalid={!!errors.email} {...register('email')} />
</Campo>
<Campo etiqueta="Teléfono" error={errors.telefono}>
<input inputMode="numeric" className={claseInput} autoComplete="tel" aria-invalid={!!errors.telefono} {...register('telefono')} />
</Campo>
<Campo etiqueta="Código postal" error={errors.cp}>
<input inputMode="numeric" className={claseInput} autoComplete="postal-code" aria-invalid={!!errors.cp} {...register('cp')} />
</Campo>
<Campo etiqueta="Calle y número" error={errors.calle}>
<input className={claseInput} autoComplete="street-address" aria-invalid={!!errors.calle} {...register('calle')} />
</Campo>
<Campo etiqueta="Ciudad" error={errors.ciudad}>
<input className={claseInput} autoComplete="address-level2" aria-invalid={!!errors.ciudad} {...register('ciudad')} />
</Campo>
</div>
<fieldset>
<legend className="mb-2 font-bold">Método de pago</legend>
<div className="grid gap-2 sm:grid-cols-3">
{[
['tarjeta', '💳 Tarjeta'],
['transferencia', '🏦 Transferencia'],
['oxxo', '🏪 OXXO']
].map(([valor, texto]) => (
<label key={valor} className="flex cursor-pointer items-center gap-2 rounded-lg border p-3 has-[:checked]:border-marca-600 has-[:checked]:bg-marca-50">
<input type="radio" value={valor} {...register('pago')} />
{texto}
</label>
))}
</div>
{errors.pago && <p className="mt-1 text-sm text-red-600">{errors.pago.message}</p>}
</fieldset>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" {...register('terminos')} />
Acepto los términos y el aviso de privacidad
</label>
{errors.terminos && <p className="text-sm text-red-600">{errors.terminos.message}</p>}
</section>
<aside className="h-fit space-y-4 rounded-2xl bg-white p-6">
<h2 className="text-lg font-bold">Tu pedido ({items.length})</h2>
<ResumenTotales items={items} />
<button
type="submit"
disabled={isSubmitting}
className="w-full rounded-xl bg-marca-600 py-3 font-semibold text-white hover:bg-marca-700 disabled:opacity-60"
>
{isSubmitting ? 'Procesando pago…' : 'Confirmar pedido'}
</button>
<p className="text-center text-xs text-gray-500">🔒 Pago simulado: no se cobrará nada.</p>
</aside>
</form>
)
}jsx
import { Link, useLocation, useParams } from 'react-router'
import { moneda } from '../lib/formato'
export default function Confirmacion() {
const { folio } = useParams()
const { state } = useLocation()
const pedido = state?.pedido
return (
<div className="mx-auto max-w-lg rounded-2xl bg-white p-8 text-center">
<title>¡Pedido confirmado! | Tiendita</title>
<p className="text-6xl">🎉</p>
<h1 className="mt-4 text-2xl font-bold">¡Gracias por tu compra!</h1>
<p className="mt-2 text-gray-600">Tu número de pedido es <strong>{folio}</strong></p>
{pedido && (
<p className="mt-2 text-gray-600">
Enviamos la confirmación a {pedido.email}. Total pagado: <strong>{moneda(pedido.totales.total)}</strong>
</p>
)}
<Link to="/catalogo" className="mt-6 inline-block rounded-full bg-marca-600 px-6 py-3 font-semibold text-white">
Seguir comprando
</Link>
</div>
)
}Del proyecto de práctica a una tienda real
Este frontend está listo para conectarse a un backend. Para un cliente real:
- Backend propio: reemplaza DummyJSON por tu API de Node (productos en base de datos, pedidos, usuarios). Cambia
BASEporimport.meta.env.VITE_API_URL. - Precios en el servidor: el backend debe recalcular el total con los precios de su base de datos. Nunca confíes en el total que envía el navegador.
- Pagos reales: integra Stripe Checkout o Mercado Pago desde el backend (el frontend sólo redirige a la página de pago o usa sus componentes oficiales).
- SEO: si la tienda necesita posicionarse en Google, migra a Next.js para renderizar productos en el servidor.
- Analítica y rendimiento: carga diferida de páginas, imágenes optimizadas y eventos de conversión.
Seguridad
Nunca proceses tarjetas directamente en tu frontend ni las mandes a tu servidor. Usa los formularios y SDK oficiales del proveedor de pagos (cumplen PCI DSS por ti).
Retos
- Agrega una lista de deseos (corazón en cada tarjeta) persistida con Zustand y una página
/favoritos. - Agrega cupones de descuento en el carrito (
BIENVENIDA10= 10 %), reflejados en el total. - Escribe tests para
calcularTotalesy para el flujo "agregar al carrito → ver contador actualizado". - Agrega filtro por rango de precio en el catálogo.
Ver solución del reto 1
js
// src/features/favoritos/store.js
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export const useFavoritos = create(
persist(
(set, get) => ({
productos: [], // guardamos el producto resumido para mostrarlo sin pedir a la API
alternar: (producto) =>
set((s) =>
s.productos.some((p) => p.id === producto.id)
? { productos: s.productos.filter((p) => p.id !== producto.id) }
: { productos: [...s.productos, producto] }
),
esFavorito: (id) => get().productos.some((p) => p.id === id)
}),
{ name: 'tiendita-favoritos' }
)
)jsx
// Botón dentro de TarjetaProducto.jsx
import { useFavoritos } from '../favoritos/store'
// dentro del componente:
const esFavorito = useFavoritos((s) => s.productos.some((p) => p.id === producto.id))
const alternar = useFavoritos((s) => s.alternar)
// en el JSX, dentro del Link de la imagen (o encima con position absolute):
<button
onClick={(e) => { e.preventDefault(); alternar(producto) }}
aria-pressed={esFavorito}
aria-label={esFavorito ? 'Quitar de favoritos' : 'Agregar a favoritos'}
className="absolute right-3 top-3 rounded-full bg-white p-2 shadow"
>
{esFavorito ? '❤️' : '🤍'}
</button>jsx
// src/pages/Favoritos.jsx (agrega { path: 'favoritos', element: <Favoritos /> } al router)
import { useFavoritos } from '../features/favoritos/store'
import TarjetaProducto from '../features/productos/TarjetaProducto'
export default function Favoritos() {
const productos = useFavoritos((s) => s.productos)
return (
<>
<h1 className="mb-6 text-3xl font-bold">Favoritos</h1>
{productos.length === 0 ? (
<p>Aún no tienes favoritos. Toca el 🤍 en un producto.</p>
) : (
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{productos.map((p) => <TarjetaProducto key={p.id} producto={p} />)}
</div>
)}
</>
)
}Resumen
- Arquitectura por features:
carrito,productosytoasts, cada uno con su store y componentes. - TanStack Query para el catálogo (incluida la consulta infinita) y Zustand con
persistpara el carrito. - Filtros en la URL, drawer accesible, checkout validado con React Hook Form + zod.
- Para producción: backend que recalcula precios, pagos con un proveedor oficial y Next.js si importa el SEO.
- 🎉 ¡Terminaste el curso de React! El siguiente paso natural es Next.js.