Volver a proyectos
Caso de estudioEn producción

Vame Fútbol

Una tienda de camisetas de fútbol hecha para cómo compran sus clientes: desde el celular y cerrando por WhatsApp.

Rol
Fullstack
Año
2026
Stack
  • React 19
  • TypeScript
  • Vite 6
  • React Router 7
  • Supabase
  • Vercel
Ver la tienda en vivo

Contexto y problema

Vame Fútbol vende camisetas de fútbol retro y actuales. El punto de partida era un único HTML estático con 427 productos escritos a mano en el código.

La migración a React se hizo en 8 PRs encadenados, con un commit base para poder volver atrás en cualquier momento. Después, el catálogo pasó a vivir solo en Supabase, las fotos en Supabase Storage, y el stock empezó a sincronizarse por talle. En total fueron 121 commits entre julio y septiembre de 2026.

Arquitectura

La tienda no escribe en la base: lee una vista que es de la app de inventario. El catálogo se pide una vez al cargar, no en tiempo real.

El inventario lo maneja otra aplicación, kitstock-pro, que es dueña de la vista storefront_products. La tienda la consulta con la anon key, que solo tiene permiso de select sobre esa vista.

Cada vez que el catálogo llega bien, se guarda en localStorage. Si Supabase falla, la tienda muestra esa última copia en lugar de una página vacía. Para el estado no hay librería: alcanzan dos contextos, CartProvider y CatalogFilterProvider.

Tres decisiones técnicas

  1. Stock por talle, sin que un agotado corte la venta

    Decisión
    Las columnas stock_s a stock_xxxl se convierten en un stockBySize, y cada producto tiene un stock_mode: inmediato o encargo. Un talle sin stock sigue siendo seleccionable: aparece la nota “se hace por encargo” y el botón pasa a “Realizar encargo”. Al entrar, queda elegido el primer talle con stock.
    Por qué
    Un talle agotado no es una venta perdida, es un encargo. En cambio, el filtro por talle responde otra pregunta, “qué puedo comprar ahora”, y solo muestra stock confirmado mayor a cero. Los productos con stock se ordenan primero con un sort estable, y cuando queda una sola unidad aparece el aviso “Última unidad”.
    Tradeoff
    El mapa por talle se arma solo si todas las columnas traen números. Si falta alguno, todos los talles se tratan como disponibles: prefiero no esconder un producto por un dato incompleto, aunque eso puede terminar en un encargo en vez de una entrega inmediata.
  2. Carrito en el dispositivo y cierre por WhatsApp

    Decisión
    El carrito vive en localStorage (vame:cart) a través de un hook genérico useLocalStorage con un validador de tipos: si el JSON está roto, tiene otra forma o el storage no está disponible, vuelve al valor por defecto. El checkout arma un mensaje por línea (equipo, temporada, variante, talle, dorsal y cantidad) más el total, y lo abre en WhatsApp.
    Por qué
    Así compran los clientes de Vame: desde el celular y conversando. Cada línea se identifica por key|size|dorsalName|dorsalNumber, lo que corrigió un bug heredado: cantidad y borrar usaban solo el producto, así que dos líneas de la misma camiseta se pisaban. El mensaje se codifica una sola vez con encodeURIComponent, para que un dorsal con &, # o % no rompa la URL.
    Tradeoff
    No hay pago en el sitio: el pedido se confirma a mano, fuera de la tienda. Y el carrito queda en ese dispositivo, sin sincronizarse entre celular y computadora.
  3. Imágenes pensadas para el celular

    Decisión
    Las URLs de Supabase Storage se reescriben de /object/ a /render/image/ con una caja cuadrada, resize=contain y quality=75. El componente KitImage carga en diferido por defecto; con priority pasa a eager con fetchPriority="high", y mientras tanto muestra un skeleton con shimmer. La imagen del hero se precarga en index.html con sustitución de variables de Vite, y las fuentes son propias y precargadas.
    Por qué
    Casi todo el tráfico llega desde el celular. Durante el desarrollo vi fotos de unos 6 MB bajar a menos de 150 KB con la transformación, y un LCP de más de 19 segundos que dejó de aparecer. Son observaciones hechas mientras desarrollaba, no un benchmark auditado.
    Tradeoff
    Las fotos pasan a depender del servicio de transformación de Supabase, y la caja cuadrada con contain fuerza un mismo formato para todas, aunque el original no sea cuadrado.

Problemas difíciles

  • Supabase no adivina la otra dimensión

    Al transformar una imagen, Supabase no infiere la dimensión que falta. Pedir solo un ancho no alcanzaba: la solución fue una caja cuadrada explícita con resize=contain.

  • Rutas del cliente en Vercel

    En una SPA, el router vive en el navegador. Sin un rewrite hacia la app en Vercel, abrir un link directo a una página interna no encontraba nada que servir.

  • Una anon key que solo puede leer una vista

    Un incidente previo de RLS con la anon key en el mismo proyecto de Supabase dejó una regla: la anon key solo tiene select sobre storefront_products, y lo que escribe usa la service role desde el servidor.

  • Extensiones .js en el servidor

    Las funciones de Node en Vercel necesitan la extensión .js explícita en los imports. Sin ella, el import no resuelve.

Cómo se testea

200+tests unitarios

Más de 200 tests unitarios y de componentes con Vitest, jsdom y React Testing Library, repartidos en 26 archivos.

Arriba de eso, Playwright recorre la tienda de punta a punta: 6 specs y 19 tests que corren contra el build de producción, no contra el servidor de desarrollo. Por ahora se ejecutan de forma local; todavía no hay CI.

Resultados

visitantes
5.800+
páginas vistas
19.000
tráfico mobile
97%

Son los números del primer mes. El 97% de tráfico mobile confirma lo que guió el diseño: una tienda que tiene que ser liviana y cómoda en el celular, y que cierra la venta donde el cliente ya conversa.

En camino: checkout online

El siguiente paso es cobrar dentro del sitio. Está construido en una rama aparte (feat/checkout-pagos) y todavía no está mergeado. Mientras tanto, WhatsApp sigue siendo el canal principal hasta que el nuevo demuestre que funciona.

En la rama

  • Checkout en /checkout con transferencia bancaria
  • api/orders/create valida el pedido y recalcula precio, descuento y recargo por dorsal en el servidor: el cliente nunca manda un precio
  • Chequeo de stock para productos inmediato, con un 409 si no alcanza
  • Los pedidos vencen a las 24 h con un cron protegido por CRON_SECRET

Todavía no

  • Pago con tarjeta vía Mercado Pago
  • Reserva de stock

Sin construir todavía.

¿Querés verla funcionando?

Vame está en producción. También podés volver a los demás proyectos.