Tema
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 devEstructura
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.jsxPaso 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,
keepPreviousDatapara 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
- Agrega la página Pedidos (
/carts) con una tabla y el detalle de cada pedido en un modal. - Agrega un modo oscuro con un botón en el header que cambie las variables CSS y se recuerde en
localStorage. - Agrega un formulario para crear producto (
POST /products/add) conuseMutationque invalide la lista. - 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
queryOptionscentraliza keys y funciones de cada recurso. - El layout con
Outlet,NavLinkyuseIsFetchingda navegación y feedback global. - Filtros y paginación en la URL con
useSearchParams;keepPreviousDatay prefetch mejoran la experiencia. - Componentes reutilizables (métricas, gráfica CSS, paginación, error, skeleton) mantienen las páginas limpias.