Servicios locales · Motor de reservas, pagos y administración para una escuela de cocina

SISTEMA DE RESERVAS DE TALLER 24

Una escuela de cocina de barrio en Barcelona vendía clases de tres horas a través de un carrito de compra. Reconstruí el sitio y el motor de reservas para que reservar una plaza sea una sola pantalla, y la persona que lleva la cocina lo opera sin tener que llamarme.

Rol
En solitario: diseño de producto, UX, arquitectura y construcción. Agentes de IA (Claude Code) como pareja de ingeniería. El cliente como único stakeholder
Cronología
Mayo – septiembre 2026 · En producción en taller-24.com desde junio
Cliente
Taller 24 · Escuela de cocina en Sants, Barcelona. Robert Betriu, chef y propietario. Joanne Gould, operaciones
Alcance
Sitio público trilingüe (es · en · ca), reservas con pago por Redsys, vales regalo con monedero, panel de administración, migración SEO
  • 1 Pantalla para reservar una plaza, sustituyendo carrito, checkout y confirmación
  • 52KB Imagen del hero móvil, en una página que antes enviaba 161 KB de HTML antes de cualquier imagen
  • 694 Casos de test en 94 archivos, más un job de CI contra un Postgres real

Un servicio con plazas limitadas, vendido como un producto físico

Taller 24 tenía marca (4,8 estrellas, 115 reseñas en Google) y un producto que funciona: tres horas cocinando con un chef, te comes lo que has hecho, te llevas las recetas a casa. La web hacía lo contrario de lo que hace el negocio.

Para el cliente, el flujo era el de una tienda. Añadir al carrito, ir al checkout, "completar compra". Para algo con una fecha, una hora y plazas contadas. Las fechas y las plazas libres no aparecían hasta bien entrado el proceso.

Para Joanne, que lleva la operación, era un wp-admin con 25 plugins. Un producto llamado "Test" publicado en el catálogo. El mapa de la home apuntaba a King Street, Londres, y el teléfono tenía prefijo de Singapur. Restos de la demo de la plantilla.

Para el negocio: sin schema de curso ni de negocio local, sin meta description, unas 30 URLs zombis en el sitemap, y un rendimiento que dolía en móvil. 161 KB de HTML antes de una sola imagen, y un time to first byte por encima de un segundo. La tensión de fondo era simple. Un servicio con aforo no es un producto físico, y cada capa del sitio anterior estaba construida como si lo fuera.

Qué lo hizo difícil

  • Dinero real y un aforo real. Un bug de concurrencia es una persona en la puerta sin plaza en la mesa. Nada de overbooking, y ninguna plaza bloqueada para siempre por pagos que nunca llegan
  • Una sola operadora, sin perfil técnico. Joanne recibe reservas por teléfono y WhatsApp además de online. Si el panel exigía aprender algo, no lo iba a usar
  • Infraestructura de nivel gratuito. Vercel Hobby permite un cron al día. Cualquier diseño que dependiera de "un proceso cada cinco minutos" quedaba descartado desde el principio
  • Marco legal español. Normativa de cookies de la agencia de protección de datos, sin derecho de desistimiento en servicios con fecha, facturas con numeración fiscal sin huecos, IVA del 21%
  • Cinco públicos, un sitio. Vecino, quien regala, familia con niños, empresa, turista. Sin una prioridad, la home sería para todos y por tanto para nadie

Lo que asumí, y lo que no era mío

Todo el diseño y toda la construcción: producto, UX, el modelo de datos, el motor de reservas, el panel de administración, el sitio público en tres idiomas, la migración SEO. Los agentes de IA trabajaron como mi pareja de ingeniería; cada decisión de producto y arquitectura fue mía. El cliente era el único stakeholder.

Dos cosas no fueron mías y merece la pena decirlo. La animación del hero pasó por cuatro versiones a petición de Robert. Y la decisión de producto más importante del proyecto la disparó un bug que reportó Joanne, no yo.

Reservar es una acción dentro de la página del taller, no un recorrido aparte

Esa única frase produjo las reglas que sobrevivieron a cada rediseño posterior. El widget de reserva vive incrustado en cada página de taller; no hay página de checkout. Cada tarjeta del catálogo muestra la próxima sesión y las plazas que quedan, sin "haz clic para saberlo". El correo es la identidad; el cliente nunca se registra ni inicia sesión. Y las reseñas son prueba, no decoración: van en el hero, en el footer y en los datos estructurados.

La segunda decisión de orden fue la prioridad de público. Le pregunté al cliente qué tipo de cliente conservaría si solo pudiera quedarse con uno. La respuesta, el vecino adulto buscando un plan para el sábado, fijó el texto de la home, el filtro por defecto del catálogo, y qué landings se construyeron primero y cuáles se aplazaron.

Construir el motor de reservas en vez de comprarlo

Nada de WooCommerce Bookings, ningún plugin de reservas. Un modelo propio sobre Postgres con una retención de plaza de 30 minutos y un bloqueo de fila durante la transacción.

Por qué: el plugin imponía el carrito. Y en el sistema propio, el aforo de una sesión nunca se guarda como un contador. Siempre se deriva de las reservas confirmadas más las pendientes aún no caducadas. Eso elimina la única clase de inconsistencia que sería imposible de auditar después.

Contrapartida: más código propio en la ruta del dinero. Lo compensé con un job de CI que levanta un Postgres real, aplica el esquema y ejecuta escrituras reales. Esa guarda atrapó tres bugs que unos tests con una base de datos simulada nunca habrían visto, uno de ellos en el primer canje real de un vale.

Caducar plazas al leer, no con un cron

La retención de 30 minutos se libera de forma perezosa en la propia ruta de lectura y dentro de la transacción de reserva. El cron diario es solo una red de respaldo.

Por qué: el diseño original contaba con un barrido cada cinco minutos. Vercel Hobby lo rechazó en el despliegue, no en CI, que pasaba. En vez de subir de plan por una tarea de limpieza, moví la caducidad al sitio donde importa: el instante en que alguien mira las plazas o intenta reservar.

Resultado: el contador público nunca depende de un proceso externo, y el sitio se quedó en el plan gratuito.

El vale de cancelación se convirtió en un monedero

Cancelar con más de 72 horas de antelación no devuelve dinero. Genera un vale. Al principio ese vale era una sesión. Tras un incidente real se convirtió en un saldo en euros, con pago parcial con tarjeta y el resto como crédito reutilizable.

Por qué: Joanne reportó que dos códigos de "45 euros" dejaban gratis un taller de 50 euros. No era un bug de código. El modelo "un vale equivale a una sesión" no distinguía entre un bono de clases y un regalo con un valor. El arreglo de producto fue separar los dos. Los bonos siguen siendo sesiones. Los regalos y las cancelaciones son dinero.

Contrapartida: tres migraciones de esquema en producción, un libro de movimientos de solo-añadir, y un desglose vale-versus-tarjeta en el widget, el correo y el panel de administración. Descarté todo lo demás: ninguna tabla nueva de tarjetas regalo, sin multidivisa.

Un detalle que importa: el localizador que ve el cliente se deriva del identificador de la reserva. Nunca es el número de factura, que es un identificador fiscal interno. Ese campo se eliminó del tipo del correo para que el compilador impida que alguien lo reintroduzca.

El panel no es la marca. Es una herramienta.

El sitio público es crema, berenjena y albaricoque, editorial. El panel de administración cambia de registro: superficies grises sobre blanco, tablas densas, una sola tinta de acento. Solo en castellano.

Por qué: la primera versión del panel heredaba los tokens públicos y se sentía como una extensión del marketing. Joanne necesitaba una herramienta. Las piezas que más usa salieron de observar cómo trabaja: un bloque de "hoy y mañana" en el dashboard, una hoja de cocina en PDF por sesión con alergias, un botón que copia los teléfonos de los asistentes para pegarlos en WhatsApp, y un formulario de reserva manual para las que llegan por teléfono.

Lo que quité: el editor de talleres exigía traducciones al inglés y al catalán en cada guardado. Bloqueaba a Joanne. Las traducciones pasaron a ser opcionales tras un interruptor. El castellano es el canónico.

Fecha y plazas a la vista

Los filtros de cocina y público viven en la URL, no en el estado del cliente. Cada tarjeta muestra la próxima sesión y una insignia de disponibilidad de tres niveles. "Últimas plazas" aparece desde tres plazas hacia abajo; "completo" también existe, aunque ninguna sesión estaba en ese estado el día de la captura.

La confirmación lleva lo necesario y nada más: correo con localizador, archivo de calendario, cómo llegar y qué traer. Una factura simplificada en PDF, la completa solo si se pide.

Dos pasos en el móvil, sin cuenta

Próximas fechas con las plazas que quedan en cada una, un selector de asistentes limitado por el aforo, un solo campo que acepta un código promocional o un vale, un resumen con desglose y pago a través de Redsys. Teclado y reduced-motion respetados. La disponibilidad es información de primer nivel, no un error tardío.

Un panel para una sola persona

Reservas, sesiones en un calendario, talleres con selector de imagen, vales y códigos, pagos, correos enviados, registro de auditoría. Cada acción de servidor lleva su propia comprobación de autenticación, independiente del middleware. Los nombres y correos de las capturas son ficticios.

Donde la realidad cambió el diseño

El huso horario falló dos veces. Las sesiones se guardan en UTC y Vercel corre en UTC. Un formateador sin la zona horaria de Madrid mostraba las horas dos horas antes en verano. Lo arreglé en el sitio público, y semanas después reapareció en correos, PDFs y el panel. El arreglo no fue un tercer parche: un solo formateador para cada superficie, más una regla de lint que falla si alguien instancia otro.

La caché tenía un coste oculto. En Next 16 cada página no es una entrada de caché sino unas ocho, por el prefetch por segmento. Un TTL corto costaba nueve veces lo que parecía. Lo subí a una hora y trasladé la frescura a invalidación bajo demanda: las ocho rutas que tocan el aforo (reserva pública, reserva con vale, reserva manual, cancelación, webhook confirmado, webhook rechazado, barrido de retenciones, edición de sesión) invalidan las páginas públicas. El test que protege esto no comprueba que se llamó a una función con una cadena de texto. Comprueba que la ruta invalidada existe en disco, porque una ruta borrada meses antes seguía "invalidando" sin que nada fallara.

Un parche de seguridad rompió los pagos. Una subida automática de dependencias tocó la instrumentación de errores que solo corre en producción. Pasó CI, build y despliegue. Falló en la primera reserva real. Mi primer diagnóstico fue erróneo (culpé a la subida de dependencias); la causa real era una columna que faltaba en una cláusula RETURNING. Ambas lecciones están escritas en el documento de aprendizajes del proyecto: probar un pago real antes de fusionar cambios de runtime, y verificar el SQL en crudo contra un Postgres real, no un mock.

Medido, observado, y lo que no puedo afirmar

Medido, sitio antiguo frente al nuevo: 78 scripts en la home a páginas estáticas con JavaScript diferido. 66 hojas de estilo a un solo bundle. 161 KB de HTML de la home a un hero móvil en AVIF de 52 KB. Un time to first byte de 1,1 a 1,6 segundos en hosting compartido a páginas estáticas servidas desde una CDN. Un flujo de carrito, checkout y confirmación a una sola pantalla dentro de la página del taller. Sin datos estructurados a Course, LocalBusiness, FoodEstablishment, BreadcrumbList y AggregateRating. Cobertura: 694 casos de test en 94 archivos, más un job de CI sobre Postgres real. 191 pull requests en cuatro meses.

  • Observado, sin cifra: el sitio recibe reservas reales desde junio de 2026. Los incidentes de producción anteriores existen porque hay clientes reales reservando y canjeando vales
  • Joanne usa el panel a diario: reservas manuales, hoja de cocina, vales. Los bugs que ha reportado son bugs de negocio (el vale de 45 euros), no bugs de uso
  • Las 93 URLs antiguas que Search Console marcaba como error ahora redirigen a su equivalente
  • Lo que no puedo afirmar: no tengo una línea base de reservas mensuales ni de conversión del sitio anterior, así que no puedo atribuir un aumento de ventas al rediseño. El embudo de GA4 está instrumentado desde junio. Con seis meses de datos, esta sección cambia

Lo caro fue todo lo que solo se rompe en producción

Diseñar el widget de reserva no fue la parte costosa. Los husos horarios, el SQL contra una base de datos real, la instrumentación que no corre en los tests sí lo fueron. La respuesta que funcionó no fue "más cuidado". Fue convertir cada incidente en una guarda automática (una regla de lint, un job de CI con Postgres real, un test que mira el disco) y escribirlo en un documento de aprendizajes que cualquier sesión futura lee antes de tocar nada.

Y la decisión de producto más importante no fue mía. La tomó un vale de 45 euros aplicado a un taller de 50 euros.

Casos de Estudio