Skip to content

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 localStorage y 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/vite

Estructura ​

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.jsx

Paso 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:

  1. Backend propio: reemplaza DummyJSON por tu API de Node (productos en base de datos, pedidos, usuarios). Cambia BASE por import.meta.env.VITE_API_URL.
  2. 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.
  3. 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).
  4. SEO: si la tienda necesita posicionarse en Google, migra a Next.js para renderizar productos en el servidor.
  5. 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 ​

  1. Agrega una lista de deseos (corazón en cada tarjeta) persistida con Zustand y una página /favoritos.
  2. Agrega cupones de descuento en el carrito (BIENVENIDA10 = 10 %), reflejados en el total.
  3. Escribe tests para calcularTotales y para el flujo "agregar al carrito → ver contador actualizado".
  4. 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, productos y toasts, cada uno con su store y componentes.
  • TanStack Query para el catálogo (incluida la consulta infinita) y Zustand con persist para 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.

Academia Web — aprende, copia y construye.