Skip to content

Proyecto: dashboard ​

Construirás un panel de administración que consume una API real: métricas, gráficas, tablas con búsqueda y paginación, detalle de cada registro y navegación con rutas. Es el tipo de proyecto que más piden las empresas: paneles internos, CRMs, panel de ventas.

Qué vas a construir ​

AdminPro, un dashboard que usa la API pública DummyJSON:

  • Resumen: tarjetas de métricas (productos, usuarios, pedidos, ingresos) y gráfica de barras por categoría hecha con CSS.
  • Productos: tabla con búsqueda (con debounce), filtro por categoría, orden y paginación en la URL.
  • Detalle de producto: galería, información y reseñas.
  • Usuarios: listado en tarjetas con búsqueda.
  • Layout con barra lateral responsive, estados de carga (skeletons), errores con reintento y página 404.

Tecnologías: React 19, Vite, React Router v7, TanStack Query v5, CSS Modules.

Crear el proyecto ​

bash
npm create vite@latest adminpro -- --template react
cd adminpro
npm install
npm i react-router @tanstack/react-query @tanstack/react-query-devtools
npm run dev

Estructura ​

txt
src/
├── api/
│   ├── cliente.js
│   └── queries.js
├── components/
│   ├── Layout.jsx
│   ├── Layout.module.css
│   ├── TarjetaMetrica.jsx
│   ├── GraficaBarras.jsx
│   ├── Paginacion.jsx
│   ├── EstadoError.jsx
│   └── Skeleton.jsx
├── hooks/
│   └── useDebounce.js
├── pages/
│   ├── Resumen.jsx
│   ├── Productos.jsx
│   ├── DetalleProducto.jsx
│   ├── Usuarios.jsx
│   └── NoEncontrado.jsx
├── lib/formato.js
├── router.jsx
├── index.css
└── main.jsx

Paso 1: capa de datos ​

js
const BASE_URL = import.meta.env.VITE_API_URL ?? 'https://dummyjson.com'

export async function apiGet(ruta, { signal } = {}) {
  const res = await fetch(`${BASE_URL}${ruta}`, { signal })
  if (!res.ok) {
    const error = new Error(res.status === 404 ? 'No encontrado' : `Error ${res.status} al cargar datos`)
    error.status = res.status
    throw error
  }
  return res.json()
}
js
import { queryOptions, keepPreviousData } from '@tanstack/react-query'
import { apiGet } from './cliente'

// queryOptions agrupa key + función: se reutiliza en hooks y prefetch
export const resumenQuery = () =>
  queryOptions({
    queryKey: ['resumen'],
    queryFn: async ({ signal }) => {
      const [productos, usuarios, carritos] = await Promise.all([
        apiGet('/products?limit=0&select=category,price,stock,rating', { signal }),
        apiGet('/users?limit=0&select=id', { signal }),
        apiGet('/carts?limit=0', { signal })
      ])

      // Agrupa productos por categoría para la gráfica
      const porCategoria = Object.entries(Object.groupBy(productos.products, (p) => p.category))
        .map(([categoria, lista]) => ({ categoria, total: lista.length }))
        .toSorted((a, b) => b.total - a.total)

      return {
        totalProductos: productos.total,
        totalUsuarios: usuarios.total,
        totalPedidos: carritos.total,
        ingresos: carritos.carts.reduce((s, c) => s + c.discountedTotal, 0),
        sinStock: productos.products.filter((p) => p.stock < 10).length,
        ratingPromedio: productos.products.reduce((s, p) => s + p.rating, 0) / productos.products.length,
        porCategoria,
        ultimosPedidos: carritos.carts.slice(0, 5)
      }
    },
    staleTime: 5 * 60 * 1000
  })

export const categoriasQuery = () =>
  queryOptions({
    queryKey: ['categorias'],
    queryFn: ({ signal }) => apiGet('/products/category-list', { signal }),
    staleTime: Infinity // casi nunca cambian
  })

export const productosQuery = ({ pagina, limite, q, categoria, orden }) =>
  queryOptions({
    queryKey: ['productos', { pagina, limite, q, categoria, orden }],
    queryFn: ({ signal }) => {
      const params = new URLSearchParams({
        limit: String(limite),
        skip: String((pagina - 1) * limite),
        select: 'title,price,stock,category,thumbnail,rating,brand'
      })
      if (orden) {
        const [campo, dir] = orden.split(':')
        params.set('sortBy', campo)
        params.set('order', dir)
      }
      let ruta = `/products?${params}`
      if (q) ruta = `/products/search?q=${encodeURIComponent(q)}&${params}`
      else if (categoria) ruta = `/products/category/${categoria}?${params}`
      return apiGet(ruta, { signal })
    },
    placeholderData: keepPreviousData
  })

export const productoQuery = (id) =>
  queryOptions({
    queryKey: ['producto', id],
    queryFn: ({ signal }) => apiGet(`/products/${id}`, { signal })
  })

export const usuariosQuery = (q) =>
  queryOptions({
    queryKey: ['usuarios', q],
    queryFn: ({ signal }) =>
      apiGet(
        q
          ? `/users/search?q=${encodeURIComponent(q)}&limit=30`
          : '/users?limit=30&select=firstName,lastName,email,image,company,address,role',
        { signal }
      ),
    placeholderData: keepPreviousData
  })
js
export const moneda = (n) =>
  new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(n)

export const numero = (n) => new Intl.NumberFormat('es-MX').format(n)
js
import { useEffect, useState } from 'react'

export function useDebounce(valor, ms = 400) {
  const [v, setV] = useState(valor)
  useEffect(() => {
    const id = setTimeout(() => setV(valor), ms)
    return () => clearTimeout(id)
  }, [valor, ms])
  return v
}

Paso 2: arranque, rutas y layout ​

jsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { RouterProvider } from 'react-router'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { router } from './router'
import './index.css'

const queryClient = new QueryClient({
  defaultOptions: { queries: { staleTime: 60_000, retry: 1, refetchOnWindowFocus: false } }
})

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <QueryClientProvider client={queryClient}>
      <RouterProvider router={router} />
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  </StrictMode>
)
jsx
import { createBrowserRouter } from 'react-router'
import Layout from './components/Layout'
import Resumen from './pages/Resumen'
import Productos from './pages/Productos'
import DetalleProducto from './pages/DetalleProducto'
import Usuarios from './pages/Usuarios'
import NoEncontrado from './pages/NoEncontrado'

export const router = createBrowserRouter([
  {
    path: '/',
    element: <Layout />,
    children: [
      { index: true, element: <Resumen /> },
      { path: 'productos', element: <Productos /> },
      { path: 'productos/:id', element: <DetalleProducto /> },
      { path: 'usuarios', element: <Usuarios /> },
      { path: '*', element: <NoEncontrado /> }
    ]
  }
])
jsx
import { useState } from 'react'
import { NavLink, Outlet, useLocation } from 'react-router'
import { useIsFetching } from '@tanstack/react-query'
import styles from './Layout.module.css'

const ENLACES = [
  { to: '/', texto: 'Resumen', icono: '📊', end: true },
  { to: '/productos', texto: 'Productos', icono: '📦' },
  { to: '/usuarios', texto: 'Usuarios', icono: '👥' }
]

export default function Layout() {
  const [menuAbierto, setMenuAbierto] = useState(false)
  const cargando = useIsFetching() > 0 // ¿hay alguna query cargando?
  const { pathname } = useLocation()

  return (
    <div className={styles.contenedor}>
      {cargando && <div className={styles.barraCarga} aria-hidden="true" />}

      <aside className={`${styles.sidebar} ${menuAbierto ? styles.abierto : ''}`}>
        <div className={styles.logo}>⚡ AdminPro</div>
        <nav>
          {ENLACES.map((e) => (
            <NavLink
              key={e.to}
              to={e.to}
              end={e.end}
              onClick={() => setMenuAbierto(false)}
              className={({ isActive }) => `${styles.enlace} ${isActive ? styles.activo : ''}`}
            >
              <span aria-hidden="true">{e.icono}</span> {e.texto}
            </NavLink>
          ))}
        </nav>
      </aside>

      {menuAbierto && <div className={styles.fondo} onClick={() => setMenuAbierto(false)} />}

      <div className={styles.principal}>
        <header className={styles.header}>
          <button
            className={styles.hamburguesa}
            onClick={() => setMenuAbierto(true)}
            aria-label="Abrir menú"
          >
            ☰
          </button>
          <span className={styles.ruta}>{pathname === '/' ? 'Resumen' : pathname.slice(1)}</span>
          <img className={styles.avatar} src="https://dummyjson.com/icon/admin/40" alt="Admin" />
        </header>
        <main className={styles.contenido}>
          <Outlet />
        </main>
      </div>
    </div>
  )
}
css
.contenedor { display: flex; min-height: 100vh; }
.barraCarga { position: fixed; top: 0; left: 0; height: 3px; width: 100%; z-index: 50;
  background: linear-gradient(90deg, transparent, var(--primario), transparent);
  background-size: 200% 100%; animation: carga 1s linear infinite; }
@keyframes carga { from { background-position: 200% 0; } to { background-position: -200% 0; } }

.sidebar { width: 240px; flex-shrink: 0; background: var(--sidebar); color: #e5e7eb; padding: 1.25rem 0.75rem;
  position: sticky; top: 0; height: 100vh; }
.logo { font-size: 1.25rem; font-weight: 800; padding: 0 0.75rem 1.5rem; }
.enlace { display: flex; gap: 0.6rem; padding: 0.65rem 0.75rem; border-radius: 8px; color: inherit; text-decoration: none; }
.enlace:hover { background: rgb(255 255 255 / 0.06); }
.activo { background: var(--primario); color: white; }

.principal { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.header { display: flex; align-items: center; gap: 1rem; padding: 0.75rem 1.5rem; background: var(--tarjeta);
  border-bottom: 1px solid var(--borde); position: sticky; top: 0; z-index: 10; }
.ruta { text-transform: capitalize; font-weight: 600; flex: 1; }
.avatar { width: 36px; height: 36px; border-radius: 50%; }
.hamburguesa { display: none; border: 0; background: none; font-size: 1.4rem; cursor: pointer; }
.contenido { padding: 1.5rem; }
.fondo { display: none; }

@media (max-width: 860px) {
  .sidebar { position: fixed; left: 0; z-index: 40; transform: translateX(-100%); transition: transform 0.25s; }
  .sidebar.abierto { transform: translateX(0); }
  .hamburguesa { display: block; }
  .fondo { display: block; position: fixed; inset: 0; background: rgb(0 0 0 / 0.4); z-index: 30; }
  .contenido { padding: 1rem; }
}
css
:root {
  --primario: #6d28d9;
  --fondo: #f4f4f8;
  --tarjeta: #ffffff;
  --sidebar: #17112b;
  --texto: #1f2937;
  --suave: #6b7280;
  --borde: #e5e7eb;
  font-family: system-ui, -apple-system, sans-serif;
  color: var(--texto);
  background: var(--fondo);
}
* { box-sizing: border-box; }
body { margin: 0; }
h1 { font-size: 1.5rem; margin: 0 0 1.25rem; }
.tarjeta { background: var(--tarjeta); border: 1px solid var(--borde); border-radius: 12px; padding: 1.25rem; }
.grid-metricas { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
.grid-2 { display: grid; grid-template-columns: 2fr 1fr; gap: 1rem; }
@media (max-width: 1000px) { .grid-2 { grid-template-columns: 1fr; } }
.controles { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem; }
input, select, button { font: inherit; padding: 0.5rem 0.75rem; border: 1px solid var(--borde); border-radius: 8px; background: var(--tarjeta); }
button { cursor: pointer; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 0.65rem; border-bottom: 1px solid var(--borde); }
th { font-size: 0.8rem; text-transform: uppercase; color: var(--suave); }
.tabla-scroll { overflow-x: auto; }
.badge { font-size: 0.75rem; padding: 2px 8px; border-radius: 99px; background: #ede9fe; color: #5b21b6; }
.badge.rojo { background: #fee2e2; color: #b91c1c; }
.skeleton { background: linear-gradient(90deg, #eee 25%, #f5f5f5 50%, #eee 75%); background-size: 200% 100%;
  animation: brillo 1.2s infinite; border-radius: 8px; }
@keyframes brillo { from { background-position: 200% 0; } to { background-position: -200% 0; } }

Paso 3: componentes reutilizables ​

jsx
export default function TarjetaMetrica({ titulo, valor, icono, detalle, alerta = false }) {
  return (
    <article className="tarjeta" style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
      <span style={{ fontSize: '2rem' }} aria-hidden="true">{icono}</span>
      <div>
        <p style={{ margin: 0, color: 'var(--suave)', fontSize: '0.85rem' }}>{titulo}</p>
        <p style={{ margin: '2px 0', fontSize: '1.6rem', fontWeight: 800 }}>{valor}</p>
        {detalle && <small style={{ color: alerta ? '#b91c1c' : 'var(--suave)' }}>{detalle}</small>}
      </div>
    </article>
  )
}
jsx
// Gráfica de barras horizontal sólo con CSS: sin librerías
export default function GraficaBarras({ datos, etiqueta, valor, max = 8 }) {
  const visibles = datos.slice(0, max)
  const mayor = Math.max(...visibles.map((d) => d[valor]))

  return (
    <ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'grid', gap: 10 }}>
      {visibles.map((d) => {
        const porcentaje = (d[valor] / mayor) * 100
        return (
          <li key={d[etiqueta]}>
            <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.85rem' }}>
              <span style={{ textTransform: 'capitalize' }}>{d[etiqueta].replace(/-/g, ' ')}</span>
              <strong>{d[valor]}</strong>
            </div>
            <div style={{ height: 10, background: 'var(--borde)', borderRadius: 99, overflow: 'hidden' }}>
              <div
                style={{
                  width: `${porcentaje}%`,
                  height: '100%',
                  background: 'var(--primario)',
                  borderRadius: 99,
                  transition: 'width 0.6s ease'
                }}
              />
            </div>
          </li>
        )
      })}
    </ul>
  )
}
jsx
export default function Paginacion({ pagina, totalPaginas, onCambiar, deshabilitado }) {
  if (totalPaginas <= 1) return null
  return (
    <nav aria-label="Paginación" style={{ display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'center', marginTop: 16 }}>
      <button onClick={() => onCambiar(pagina - 1)} disabled={pagina <= 1 || deshabilitado}>← Anterior</button>
      <span>Página {pagina} de {totalPaginas}</span>
      <button onClick={() => onCambiar(pagina + 1)} disabled={pagina >= totalPaginas || deshabilitado}>Siguiente →</button>
    </nav>
  )
}
jsx
export default function EstadoError({ error, onReintentar }) {
  return (
    <div role="alert" className="tarjeta" style={{ borderColor: '#fecaca', background: '#fef2f2', color: '#991b1b' }}>
      <strong>No se pudo cargar la información.</strong>
      <p style={{ margin: '4px 0 12px' }}>{error?.message}</p>
      {onReintentar && <button onClick={onReintentar}>Reintentar</button>}
    </div>
  )
}
jsx
export default function Skeleton({ alto = 20, ancho = '100%', cantidad = 1 }) {
  return Array.from({ length: cantidad }, (_, i) => (
    <div key={i} className="skeleton" style={{ height: alto, width: ancho, marginBottom: 8 }} aria-hidden="true" />
  ))
}

Paso 4: páginas ​

jsx
import { useQuery } from '@tanstack/react-query'
import { resumenQuery } from '../api/queries'
import TarjetaMetrica from '../components/TarjetaMetrica'
import GraficaBarras from '../components/GraficaBarras'
import EstadoError from '../components/EstadoError'
import Skeleton from '../components/Skeleton'
import { moneda, numero } from '../lib/formato'

export default function Resumen() {
  const { data, isPending, isError, error, refetch } = useQuery(resumenQuery())

  if (isPending) {
    return (
      <>
        <h1>Resumen</h1>
        <div className="grid-metricas"><Skeleton alto={90} cantidad={4} /></div>
        <Skeleton alto={300} />
      </>
    )
  }
  if (isError) return <EstadoError error={error} onReintentar={refetch} />

  return (
    <>
      <title>Resumen | AdminPro</title>
      <h1>Resumen</h1>

      <section className="grid-metricas">
        <TarjetaMetrica titulo="Ingresos" valor={moneda(data.ingresos)} icono="💰" detalle={`${data.totalPedidos} pedidos`} />
        <TarjetaMetrica titulo="Productos" valor={numero(data.totalProductos)} icono="📦"
          detalle={`${data.sinStock} con stock bajo`} alerta={data.sinStock > 0} />
        <TarjetaMetrica titulo="Usuarios" valor={numero(data.totalUsuarios)} icono="👥" />
        <TarjetaMetrica titulo="Calificación" valor={`${data.ratingPromedio.toFixed(1)} ⭐`} icono="🏆" detalle="Promedio" />
      </section>

      <section className="grid-2">
        <article className="tarjeta">
          <h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Productos por categoría</h2>
          <GraficaBarras datos={data.porCategoria} etiqueta="categoria" valor="total" max={10} />
        </article>

        <article className="tarjeta">
          <h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Últimos pedidos</h2>
          <ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
            {data.ultimosPedidos.map((c) => (
              <li key={c.id} style={{ display: 'flex', justifyContent: 'space-between', padding: '8px 0', borderBottom: '1px solid var(--borde)' }}>
                <span>Pedido #{c.id} · {c.totalQuantity} art.</span>
                <strong>{moneda(c.discountedTotal)}</strong>
              </li>
            ))}
          </ul>
        </article>
      </section>
    </>
  )
}
jsx
import { useEffect, useState } from 'react'
import { Link, useSearchParams } from 'react-router'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { categoriasQuery, productosQuery, productoQuery } from '../api/queries'
import { useDebounce } from '../hooks/useDebounce'
import Paginacion from '../components/Paginacion'
import EstadoError from '../components/EstadoError'
import Skeleton from '../components/Skeleton'
import { moneda } from '../lib/formato'

const LIMITE = 10

export default function Productos() {
  const [params, setParams] = useSearchParams()
  const pagina = Number(params.get('pagina') ?? 1)
  const categoria = params.get('categoria') ?? ''
  const orden = params.get('orden') ?? ''
  const [texto, setTexto] = useState(params.get('q') ?? '')
  const q = useDebounce(texto.trim(), 400)
  const queryClient = useQueryClient()

  // Actualiza la URL conservando los demás parámetros
  function actualizar(cambios) {
    setParams((actuales) => {
      const nuevos = new URLSearchParams(actuales)
      Object.entries(cambios).forEach(([k, v]) => (v ? nuevos.set(k, v) : nuevos.delete(k)))
      return nuevos
    })
  }

  // Al cambiar la búsqueda, guarda en la URL y vuelve a la página 1
  useEffect(() => {
    if (q !== (params.get('q') ?? '')) actualizar({ q, pagina: '' })
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [q])

  const categorias = useQuery(categoriasQuery())
  const { data, isPending, isError, error, refetch, isPlaceholderData } = useQuery(
    productosQuery({ pagina, limite: LIMITE, q, categoria: q ? '' : categoria, orden })
  )

  const totalPaginas = data ? Math.ceil(data.total / LIMITE) : 1

  return (
    <>
      <title>Productos | AdminPro</title>
      <h1>Productos</h1>

      <div className="controles">
        <input
          type="search"
          placeholder="Buscar productos…"
          value={texto}
          onChange={(e) => setTexto(e.target.value)}
          aria-label="Buscar productos"
          style={{ flex: '1 1 220px' }}
        />
        <select
          value={categoria}
          onChange={(e) => actualizar({ categoria: e.target.value, pagina: '' })}
          disabled={Boolean(q)}
          aria-label="Categoría"
        >
          <option value="">Todas las categorías</option>
          {categorias.data?.map((c) => (
            <option key={c} value={c}>{c.replace(/-/g, ' ')}</option>
          ))}
        </select>
        <select value={orden} onChange={(e) => actualizar({ orden: e.target.value })} aria-label="Ordenar">
          <option value="">Orden por defecto</option>
          <option value="price:asc">Precio: menor a mayor</option>
          <option value="price:desc">Precio: mayor a menor</option>
          <option value="rating:desc">Mejor calificados</option>
          <option value="stock:asc">Menor stock</option>
        </select>
      </div>

      {isError ? (
        <EstadoError error={error} onReintentar={refetch} />
      ) : (
        <div className="tarjeta tabla-scroll" style={{ padding: 0, opacity: isPlaceholderData ? 0.6 : 1 }}>
          <table>
            <thead>
              <tr>
                <th>Producto</th>
                <th>Categoría</th>
                <th>Precio</th>
                <th>Stock</th>
                <th>Rating</th>
              </tr>
            </thead>
            <tbody>
              {isPending
                ? Array.from({ length: 5 }, (_, i) => (
                    <tr key={i}><td colSpan={5}><Skeleton alto={28} /></td></tr>
                  ))
                : data.products.map((p) => (
                    <tr key={p.id}>
                      <td>
                        <Link
                          to={`/productos/${p.id}`}
                          // Prefetch: empieza a cargar el detalle al pasar el mouse
                          onMouseEnter={() => queryClient.prefetchQuery(productoQuery(String(p.id)))}
                          style={{ display: 'flex', gap: 10, alignItems: 'center', color: 'inherit' }}
                        >
                          <img src={p.thumbnail} alt="" width={40} height={40} style={{ borderRadius: 6, objectFit: 'cover' }} />
                          {p.title}
                        </Link>
                      </td>
                      <td><span className="badge">{p.category}</span></td>
                      <td>{moneda(p.price)}</td>
                      <td><span className={`badge ${p.stock < 10 ? 'rojo' : ''}`}>{p.stock}</span></td>
                      <td>⭐ {p.rating.toFixed(1)}</td>
                    </tr>
                  ))}
            </tbody>
          </table>
          {data?.products.length === 0 && <p style={{ padding: 16 }}>No se encontraron productos.</p>}
        </div>
      )}

      <Paginacion
        pagina={pagina}
        totalPaginas={totalPaginas}
        onCambiar={(p) => actualizar({ pagina: p > 1 ? String(p) : '' })}
        deshabilitado={isPlaceholderData}
      />
    </>
  )
}
jsx
import { useState } from 'react'
import { Link, useParams } from 'react-router'
import { useQuery } from '@tanstack/react-query'
import { productoQuery } from '../api/queries'
import EstadoError from '../components/EstadoError'
import Skeleton from '../components/Skeleton'
import { moneda } from '../lib/formato'

export default function DetalleProducto() {
  const { id } = useParams()
  const { data: p, isPending, isError, error, refetch } = useQuery(productoQuery(id))
  const [imagen, setImagen] = useState(0)

  if (isPending) return <Skeleton alto={400} />
  if (isError) {
    return error.status === 404
      ? <p>Este producto no existe. <Link to="/productos">Volver a productos</Link></p>
      : <EstadoError error={error} onReintentar={refetch} />
  }

  return (
    <>
      <title>{`${p.title} | AdminPro`}</title>
      <Link to="/productos">← Productos</Link>
      <div className="grid-2" style={{ marginTop: 16 }}>
        <section className="tarjeta">
          <img src={p.images[imagen]} alt={p.title} style={{ width: '100%', maxHeight: 360, objectFit: 'contain' }} />
          <div style={{ display: 'flex', gap: 8, marginTop: 8, flexWrap: 'wrap' }}>
            {p.images.map((src, i) => (
              <button
                key={src}
                onClick={() => setImagen(i)}
                aria-label={`Ver imagen ${i + 1}`}
                style={{ padding: 2, borderColor: i === imagen ? 'var(--primario)' : 'var(--borde)' }}
              >
                <img src={src} alt="" width={56} height={56} style={{ objectFit: 'cover' }} />
              </button>
            ))}
          </div>
        </section>

        <section className="tarjeta">
          <span className="badge">{p.category}</span>
          <h1 style={{ margin: '8px 0' }}>{p.title}</h1>
          <p style={{ color: 'var(--suave)' }}>{p.brand}</p>
          <p style={{ fontSize: '1.8rem', fontWeight: 800, margin: 0 }}>{moneda(p.price)}</p>
          <p>{p.description}</p>
          <dl style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '4px 12px' }}>
            <dt>Stock</dt><dd style={{ margin: 0 }}>{p.stock} ({p.availabilityStatus})</dd>
            <dt>SKU</dt><dd style={{ margin: 0 }}>{p.sku}</dd>
            <dt>Garantía</dt><dd style={{ margin: 0 }}>{p.warrantyInformation}</dd>
            <dt>Envío</dt><dd style={{ margin: 0 }}>{p.shippingInformation}</dd>
          </dl>
        </section>
      </div>

      <section className="tarjeta" style={{ marginTop: 16 }}>
        <h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Reseñas ({p.reviews.length})</h2>
        {p.reviews.map((r, i) => (
          <article key={i} style={{ borderBottom: '1px solid var(--borde)', padding: '8px 0' }}>
            <strong>{r.reviewerName}</strong> · {'⭐'.repeat(r.rating)}
            <p style={{ margin: '4px 0' }}>{r.comment}</p>
          </article>
        ))}
      </section>
    </>
  )
}
jsx
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { usuariosQuery } from '../api/queries'
import { useDebounce } from '../hooks/useDebounce'
import EstadoError from '../components/EstadoError'
import Skeleton from '../components/Skeleton'

export default function Usuarios() {
  const [texto, setTexto] = useState('')
  const q = useDebounce(texto.trim())
  const { data, isPending, isError, error, refetch } = useQuery(usuariosQuery(q))

  return (
    <>
      <title>Usuarios | AdminPro</title>
      <h1>Usuarios</h1>
      <div className="controles">
        <input type="search" value={texto} onChange={(e) => setTexto(e.target.value)} placeholder="Buscar por nombre…" aria-label="Buscar usuarios" />
      </div>

      {isPending && <div className="grid-metricas"><Skeleton alto={110} cantidad={6} /></div>}
      {isError && <EstadoError error={error} onReintentar={refetch} />}

      {data && (
        <div className="grid-metricas">
          {data.users.map((u) => (
            <article key={u.id} className="tarjeta" style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
              <img src={u.image} alt="" width={56} height={56} style={{ borderRadius: '50%', background: 'var(--fondo)' }} />
              <div style={{ minWidth: 0 }}>
                <strong>{u.firstName} {u.lastName}</strong>
                <p style={{ margin: '2px 0', fontSize: '0.85rem', color: 'var(--suave)', overflow: 'hidden', textOverflow: 'ellipsis' }}>{u.email}</p>
                <small>{u.company?.title} · {u.address?.city}</small>
              </div>
            </article>
          ))}
          {data.users.length === 0 && <p>Sin resultados.</p>}
        </div>
      )}
    </>
  )
}
jsx
import { Link } from 'react-router'

export default function NoEncontrado() {
  return (
    <div style={{ textAlign: 'center', padding: '4rem 1rem' }}>
      <p style={{ fontSize: '4rem', margin: 0 }}>🧭</p>
      <h1>Página no encontrada</h1>
      <Link to="/">Volver al resumen</Link>
    </div>
  )
}

Lo que este proyecto demuestra ​

  • Estado en la URL: búsqueda, categoría, orden y página viven en la query string; puedes compartir el enlace o usar "atrás".
  • Caché y prefetch: al volver a una página ya visitada, los datos aparecen al instante; pasar el mouse por un producto precarga su detalle.
  • UX profesional: skeletons, barra de carga global, errores con reintento, keepPreviousData para paginar sin parpadeos.
  • Responsive: sidebar que se convierte en menú deslizable en móvil.

Despliégalo siguiendo la lección de despliegue (recuerda el fallback a index.html).

Retos ​

  1. Agrega la página Pedidos (/carts) con una tabla y el detalle de cada pedido en un modal.
  2. Agrega un modo oscuro con un botón en el header que cambie las variables CSS y se recuerde en localStorage.
  3. Agrega un formulario para crear producto (POST /products/add) con useMutation que invalide la lista.
  4. Exporta la tabla de productos visible a CSV.
Ver solución del reto 4
jsx
// src/lib/csv.js
export function descargarCSV(nombre, filas, columnas) {
  const escapar = (v) => `"${String(v ?? '').replace(/"/g, '""')}"`
  const encabezado = columnas.map((c) => escapar(c.titulo)).join(',')
  const cuerpo = filas.map((f) => columnas.map((c) => escapar(f[c.campo])).join(',')).join('\n')
  // BOM () para que Excel respete los acentos
  const blob = new Blob(['' + encabezado + '\n' + cuerpo], { type: 'text/csv;charset=utf-8' })
  const url = URL.createObjectURL(blob)
  const a = document.createElement('a')
  a.href = url
  a.download = nombre
  a.click()
  URL.revokeObjectURL(url)
}

// En Productos.jsx, dentro de .controles:
// <button
//   onClick={() => descargarCSV('productos.csv', data.products, [
//     { campo: 'title', titulo: 'Producto' },
//     { campo: 'category', titulo: 'Categoría' },
//     { campo: 'price', titulo: 'Precio' },
//     { campo: 'stock', titulo: 'Stock' }
//   ])}
//   disabled={!data}
// >
//   ⬇ Exportar CSV
// </button>

Resumen ​

  • Una capa de datos con queryOptions centraliza keys y funciones de cada recurso.
  • El layout con Outlet, NavLink y useIsFetching da navegación y feedback global.
  • Filtros y paginación en la URL con useSearchParams; keepPreviousData y prefetch mejoran la experiencia.
  • Componentes reutilizables (métricas, gráfica CSS, paginación, error, skeleton) mantienen las páginas limpias.

Academia Web — aprende, copia y construye.