Web pública one-page, sistema de QR para mesas, un agente al que el dueño le habla por WhatsApp o Telegram para cambiar precios, dar de alta o de baja platos y fijar el pescado del día, y auto-respuesta de reseñas de Google con revisión humana previa. Todo en un único proyecto Next.js 14 desplegable en Vercel.
Lo que distingue esto de "un chatbot conectado a una base de datos" es la frontera exacta entre lo que dice el dueño y lo que de verdad cambia en la carta:
El modelo de lenguaje nunca escribe en los datos. Cuando el dueño escribe "sube el
pulpo a 20", el LLM no toca el menú: emite una acción tipada
({"action":"update_price","category":"pescados","id":"pulpo",...}) que se valida contra
un esquema Zod, ActionSchema en lib/actions.ts. Si lo que devuelve el modelo no encaja
exactamente en ese esquema, la acción no existe — se descarta y se le pide al dueño que
reformule (ActionSchema.safeParse en lib/orchestrator.ts). El dueño confirma con SÍ, y
solo entonces una función determinista y sin LLM, applyAction (mismo fichero que el
esquema), aplica esa acción exacta sobre el menú.
De ahí salen tres propiedades:
- El mismo mensaje produce siempre el mismo cambio.
applyActiones una función pura: mismo menú de entrada + misma acción tipada = mismo resultado, siempre. La parte que puede variar —el LLM interpretando lenguaje libre— termina en cuanto se genera la acción; a partir de ahí no queda ambigüedad ni aleatoriedad. - El agente vive sobre el almacén de datos, no sobre el código. Cada acción se aplica
sobre lo que hay en Vercel KV (
lib/kv.ts), nunca sobre un fichero del repositorio. Si este mismo proyecto se despliega alguna vez para otro restaurante, cada instancia tiene su propio KV: nada de lo que haga el agente en una puede tocar el código ni los datos de otra. - Nada se aplica sin que el dueño lo confirme explícitamente. Toda acción queda en
espera (
lib/pending.ts, 10 minutos de margen) hasta que responde SÍ. Si no responde, caduca sola; si responde NO, se descarta sin tocar nada.
Hay además una barrera que no depende de que nadie se acuerde de revisarla: ninguna
acción puede tocar un campo de alérgenos, hoy ni el día que se añada uno.
scripts/test-proteccion-alergenos.ts lo comprueba en dos niveles —que ningún tipo de
acción del esquema declare ese campo, y que aplicar cada acción real contra un menú de
prueba con alérgenos no los modifique— y forma parte de npm run build: si algún día deja
de cumplirse, el despliegue se para ahí, antes de llegar a producción.
don-pepe/
├─ app/ # App Router (RSC + cliente)
│ ├─ page.tsx # One-page pública
│ ├─ carta/page.tsx # Carta optimizada para QR (revalidate 30s)
│ ├─ qr/page.tsx + actions.ts # Panel admin de QRs (basic auth por cookie)
│ ├─ api/
│ │ ├─ menu/ # GET JSON actual
│ │ ├─ menu/log/ # GET historial (basic auth o cookie, igual que /qr)
│ │ ├─ qr/ # GET PNG/SVG
│ │ ├─ qr/pdf/ # GET PDF A4 con 6 QRs por hoja
│ │ ├─ whatsapp/webhook/ # Webhook Meta Cloud o Twilio
│ │ └─ reviews/poll/ # Cron cada 15 min (vercel.json)
│ └─ layout.tsx + globals.css
├─ components/ # Hero, MenuView, SiteSections, QRAdmin
├─ data/
│ ├─ menu.json # ⭐ fuente de verdad de la carta (seed)
│ └─ sample-reviews.json # reseñas de ejemplo para modo simulación
├─ lib/
│ ├─ kv.ts # Vercel KV con fallback in-memory + JSON
│ ├─ types.ts # Menu / PlatoBase / MenuDelDia
│ ├─ format.ts # Intl.NumberFormat('es-ES', EUR)
│ ├─ actions.ts # Zod schema + applyAction
│ ├─ llm.ts # cliente OpenRouter (fetch directo)
│ ├─ sitio.ts # dominio público (SITE_URL)
│ ├─ prompts.ts # system prompts menú y reseñas
│ ├─ whatsapp.ts # proveedor Meta Cloud o Twilio
│ ├─ google.ts # GBP API con modo simulación
│ ├─ orchestrator.ts # corazón del agente (pending + confirm)
│ └─ pending.ts # confirmación SÍ/NO/EDITAR + 10 min TTL
├─ vercel.json # cron de /api/reviews/poll
├─ tailwind.config.ts
├─ next.config.mjs
└─ package.json
- Node 18.17+ (en Vercel viene de fábrica).
- Una cuenta en Vercel.
- Una API key de OpenRouter.
- Para reseñas: una app de Google Cloud con la Business Profile Performance API activada y un refresh token del dueño (ver §5).
- Para WhatsApp: un número verificado en Meta Cloud API o Twilio (ver §4).
# 1. Instala deps (no obligatorio si despliegas directo)
npm install
# 2. Sube a Vercel
vercel
# o conecta el repo desde la UI y deja que Vercel instale soloCopia .env.example a .env.local para desarrollo y rellena en Vercel para
producción (Project → Settings → Environment Variables). Lo mínimo para
arrancar la web pública:
| Var | Ejemplo | Notas |
|---|---|---|
NEXT_PUBLIC_RESTAURANT_NAME |
Don Pepe Original |
|
NEXT_PUBLIC_PHONE |
34881829728 |
fijo del local; sin + |
NEXT_PUBLIC_PHONE_DISPLAY |
881 82 97 28 |
cómo se ve en la web |
NEXT_PUBLIC_PHONE_MOBILE |
34696434042 |
móvil de reservas; sin + |
NEXT_PUBLIC_PHONE_MOBILE_DISPLAY |
696 43 40 42 |
cómo se ve en la web |
NEXT_PUBLIC_WHATSAPP |
34696434042 |
el móvil: es quien tiene WhatsApp |
NEXT_PUBLIC_ADDRESS |
Rúa Longa, 21, 15900 Padrón |
dirección postal exacta |
NEXT_PUBLIC_LAT / NEXT_PUBLIC_LNG |
42.7389 / -8.6603 |
coordenadas del mapa |
NEXT_PUBLIC_SITE_URL |
https://donpepeoriginal.es |
opcional: ya es el valor por defecto |
QR_ADMIN_PASS |
una-larga-y-aleatoria | acceso a /qr |
OPENROUTER_API_KEY |
sk-or-v1-… |
para el agente y las reseñas |
OPENROUTER_MODEL |
qwen/qwen3-30b-a3b-instruct-2507 |
el agente de WhatsApp; cambiarlo no es un despliegue |
OPENROUTER_MODEL_TRADUCCION |
qwen/qwen3-30b-a3b-instruct-2507 |
las traducciones de la carta; vacío = usa el de arriba |
Recomendado: añade también Vercel KV desde
el dashboard de Vercel. Eso inyecta automáticamente KV_REST_API_URL y
KV_REST_API_TOKEN. Sin KV, el proyecto sigue funcionando en local con un
fallback en memoria + JSON, pero los cambios del agente se perderán en cada
despliegue o cold start.
app/page.tsx es la one-page:
- Hero con
tel:y CTA a/carta. - Carta en pestañas (Entrantes / Arroces / Pescados / Carnes), precios a la derecha con tipografía monoespaciada.
- Tres tarjetas de menús.
- Horario + contacto (Llamar + WhatsApp wa.me pre-rellenado).
- Mapa embebido con OpenStreetMap (sin cookies de terceros).
- Footer con aviso legal y recordatorio de Estrella Galicia.
app/carta/page.tsx es la versión pensada para abrir desde QR. Detecta
?mesa=N y deja un log anónimo (sólo mesa + hora) en KV bajo qr:scans. Lee
la carta de KV con revalidate: 30, así que cualquier cambio del agente se ve
en menos de 30 s.
metadatacompleto enapp/layout.tsx+app/page.tsx.- JSON-LD
RestaurantconservesCuisine,openingHoursSpecification,telephone,menu,priceRange,addressygeo. Placeholders:addressygeotoman los valores deNEXT_PUBLIC_*. app/sitemap.tsyapp/robots.tslistos.lang="es-ES"en<html>. La estructura permite añadirgl-ESyendespués envolviendo strings enmessages/{locale}.json.
- Tailwind tree-shaken, sin cliente JS innecesario (la carta es la única parte interactiva).
- Imágenes cero — todo es CSS y SVG.
- Foco visible,
prefers-reduced-motionrespetado,aria-*en tabs y secciones.
Visita /qr (te pedirá la QR_ADMIN_PASS que hayas definido).
- QR general: apunta a
https://<dominio>/carta. Ideal para la entrada. - QRs por mesa: apuntan a
https://<dominio>/carta?mesa=N. Permite saber qué mesa escanea más (log enqr:scans). - Descarga cada QR en PNG 1024 o SVG (vectorial — escala sin pixelar).
- PDF A4 con 6 QRs por hoja listo para imprimir y plastificar.
Nivel de corrección de errores H (soporta manchas y arañazos), quiet zone
de 4 módulos, sin logo encima. Si quieres branding, imprímelo en la hoja
debajo del QR, no encima.
QR viejos en circulación. Hay carteles ya plastificados en las mesas
cuyo código apunta a https://donpepeoriginal.es/#carta, un ancla de la
versión anterior de la web (cuando la carta era una sección de la home, no
una ruta propia). El fragmento nunca llega al servidor, así que se resuelve
en el cliente: ver lib/anclas-heredadas.ts, cargado desde app/layout.tsx.
No borres ni vacíes ese mapa mientras esos carteles sigan en las mesas.
El bloque de reseñas de /carta y /local, y el cartelito de
/api/qr/resenas, usan una única constante: GOOGLE_REVIEWS_URL en
lib/resenas.ts. Hoy vale el literal [FALTA DATO: URL corta de reseñas de Google] a propósito -no se construye a partir del place_id ni de ningún
otro dato, porque eso podría no ser el enlace corto real de "escribir una
reseña"-. Consíguela en el propio perfil de Google Business del local
("Pedir reseñas" → copiar enlace) y sustitúyela ahí; se usa en todos los
sitios a la vez. Hasta entonces, /api/qr/resenas responde 400 con un
mensaje claro en vez de generar un cartel con un QR roto.
Las cinco estrellas del bloque de reseñas son un único enlace (no cinco distintos) al mismo destino siempre: no hay -ni puede haber sin tocar el componente- ninguna lógica que mande a un sitio distinto según la puntuación. Es a propósito: Google prohíbe expresamente filtrar reseñas según si el cliente está contento o no ("review gating").
- Crea una app en developers.facebook.com.
- Añade el producto WhatsApp.
- En API Setup, copia el Phone number ID →
WHATSAPP_PHONE_ID. - Genera un Permanent System User Token →
WHATSAPP_TOKEN. - En la sección Configuration → Webhook:
- Callback URL:
https://<dominio>/api/whatsapp/webhook - Verify token: lo que pongas en
WHATSAPP_VERIFY_TOKEN - Suscríbete a
messages.
- Callback URL:
- Añade tu número a
WHATSAPP_ALLOWED_NUMBERS(sólo dígitos, separados por comas).
- Crea un Twilio y activa el sandbox de WhatsApp (o un sender aprobado).
- Variables:
TWILIO_ACCOUNT_SID,TWILIO_AUTH_TOKEN,TWILIO_WHATSAPP_FROM(formatowhatsapp:+1...). - Apunta el webhook del sandbox a la misma URL.
Comandos (literal o aproximado):
| Mensaje del dueño | Acción |
|---|---|
sube el pulpo a 20 |
update_price (pulpo 18 → 20 €) |
baja el chuletón a 32 |
update_price |
hoy el pescado del día es rodaballo a 22 |
update_special (pescado-dia → Rodaballo, 22 €) |
quita las zamburiñas hasta nueva orden |
disable_item |
vuelve a poner las zamburiñas |
enable_item |
añade paella de bogavante a 45 |
add_item |
quita el menú churrasco |
update_menu_availability { disabled: true } |
deshacer último cambio |
revierte la última edición con snapshot |
precio del pulpo / cuánto vale el chuletón |
sólo consulta, no cambia nada |
El bot siempre responde con un resumen y espera confirmación:
Voy a: Cambiar "Pulpo a la gallega" de 18,00 € a 20,00 €. Responde SÍ para confirmar, NO para cancelar, o EDITAR: para reescribirlo. Tienes 10 minutos.
Si pasan 10 minutos sin confirmar, la acción caduca. Cualquier mensaje del número autorizado en ese intervalo cuenta como respuesta.
Si escribe alguien que no está en WHATSAPP_ALLOWED_NUMBERS, el bot
responde automáticamente:
Este número es solo para gestión interna. Para reservas, llama al 696 434 042.
(El dueño puede cambiar ese texto en app/api/whatsapp/webhook/route.ts.)
Mismo cerebro que WhatsApp (lib/orchestrator.ts, sin tocar), transporte
aparte en lib/telegram.ts + app/api/telegram/webhook/route.ts. El dueño
toca botones en vez de escribir: /plato abre categoría → plato → acción
(agotar / reactivar / eliminar / cambiar precio) → confirmar. También acepta
texto libre igual que WhatsApp ("sube el pulpo a 20"), y en ese caso también
aparecen los botones de Confirmar/Cancelar antes de aplicar nada — nunca se
aplica un cambio sin que el dueño lo toque a propósito.
Variables de entorno: TELEGRAM_BOT_TOKEN, TELEGRAM_WEBHOOK_SECRET,
TELEGRAM_ALLOWED_CHAT_IDS (ver .env.example). Vacía = nadie autorizado.
Para registrar el webhook una vez desplegado (URL pública en https),
en PowerShell:
$token = "<TELEGRAM_BOT_TOKEN>"
$secret = "<TELEGRAM_WEBHOOK_SECRET>"
$url = "https://donpepeoriginal.es/api/telegram/webhook"
Invoke-RestMethod -Method Post `
-Uri "https://api.telegram.org/bot$token/setWebhook" `
-Body (@{ url = $url; secret_token = $secret } | ConvertTo-Json) `
-ContentType "application/json"Los alérgenos no se pueden tocar desde ningún canal: ver el comentario junto
a PlatoBase en lib/types.ts y scripts/test-proteccion-alergenos.ts
(forma parte de npm run build: si algún día una acción pudiera tocarlos,
el despliegue se para ahí).
Importante sobre esta protección: es de tiempo de COMPILACIÓN, no de
tiempo de EJECUCIÓN. npm run build la comprueba antes de cada despliegue
-así que un cambio peligroso nunca llega a producción-, pero no hay ningún
guardia que se ejecute en caliente, petición a petición, mientras el bot
está atendiendo mensajes reales. No fue posible añadirlo sin tocar
lib/actions.ts/lib/orchestrator.ts, que quedan fuera de alcance (ver
condiciones de esta sesión). Si algún día se permite editar esos ficheros,
vale la pena añadir ahí también una comprobación en caliente, como cinturón
y tirantes.
Hasta que Google aprueba el acceso a la Business Profile API (puede tardar
días o semanas), el sistema corre en modo simulación: en cada
ejecución del cron toma reseñas de data/sample-reviews.json que aún no
hayan sido procesadas, genera un borrador con el LLM y se lo manda al
dueño por WhatsApp. Cuando Google apruebe, sólo hay que:
- Poner
GOOGLE_REVIEWS_ENABLED=true. - Rellenar
GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,GOOGLE_REFRESH_TOKEN,GOOGLE_LOCATION_ID. - El cron empieza a leer y responder en real.
- Crea un proyecto en Google Cloud Console.
- Habilita Business Profile Performance API.
- Configura la pantalla de OAuth con tipo "External" y añade tu cuenta como tester.
- Crea credenciales OAuth de tipo "Desktop app" → descarga el JSON.
- Usa el flujo OAuth para obtener un refresh token con los scopes:
https://www.googleapis.com/auth/business.manage
- Rellena las cuatro variables en Vercel.
GOOGLE_LOCATION_ID tiene el formato
accounts/<account-id>/locations/<location-id>. Lo encuentras en la
respuesta de accounts.locations.list.
- Cron (
/api/reviews/poll, cada 15 min segúnvercel.json) lista reseñas nuevas. - Por cada una, genera un borrador con el LLM (system prompt en
lib/prompts.ts). - Envía WhatsApp al dueño con la reseña, el borrador y la pregunta:
PUBLICAR,EDITAR: <texto>, oNO. - Si el dueño dice
PUBLICAR, se publica víaaccounts.locations.reviews.reply. - Si dice
EDITAR: <texto>, se sustituye el borrador por el texto del dueño y se le pidePUBLICARde nuevo. - Si dice
NO, se marca como vista sin responder. - Rate limit: 20 reseñas/hora para no spamear al dueño.
Dos ideas evaluadas a partir de material de referencia de otro proyecto (sin copiar ni un dato suyo, solo patrones generales). Ninguna de las dos está construida todavía; quedan aquí como encargo futuro.
Protocolo PAS para responder reseñas negativas (Pedir perdón, Asumir,
Solucionar): reconocer el fallo sin excusas, asumirlo como propio y decir
qué se va a hacer al respecto. lib/prompts.ts:REVIEW_REPLY_SYSTEM ya pide
algo parecido para 1-2 estrellas ("disculparse con elegancia... nombrar un
compromiso concreto"); formalizar el protocolo es una extensión de lo que
ya casi se hace, no un sistema nuevo.
Regla dura, sin excepción, para cuando esto se implemente: una respuesta a una reseña negativa solo puede afirmar una acción correctiva que el dueño haya confirmado que es cierta. Nada de "hemos cambiado de proveedor" ni "hemos reforzado la formación" si eso no ha pasado de verdad. Publicar una promesa falsa en nombre del dueño en su propia ficha de Google no es gestionar la reputación, es mentir en público en su nombre — y si alguien lo descubre, el daño es mayor que el de la reseña original. Cualquier implementación futura del protocolo PAS debe o bien limitarse a acciones que el sistema pueda verificar (p. ej. las que ya haya aplicado el propio dueño por Telegram/WhatsApp), o bien preguntarle primero y esperar su confirmación antes de publicar nada que prometa algo concreto.
Publicar novedades y fotos semanales en la ficha de Google (Google Posts + fotos nuevas), para mantener la ficha activa entre reseña y reseña. Hoy es una tarea manual del dueño; solo tendría sentido automatizarla si algún día se activa de verdad la API de reseñas de Google (hoy en modo simulación, ver 5.1).
Si algún día se construye un panel para que Antonio vea sus propias
métricas (nota media, reseñas pendientes, etc.), la gramática visual a
reutilizar -evaluada contra un dashboard de referencia de otro proyecto,
sin copiar su paleta- es: tarjetas planas (sombra casi inexistente, tipo
0 1px 2px rgba(0,0,0,.04)), borde teñido del color de marca a opacidad
baja (10-20%) en vez de gris neutro, esquinas muy redondeadas o en
píldora, y números grandes con interlineado mínimo (leading-none) y
tracking apretado para las cifras clave. Con la paleta YA existente de Don
Pepe (brasa/carbon/gold), no una nueva: el objetivo es que lo que vea
Antonio en su panel se sienta de la misma familia que lo que ve el cliente
en la carta.
# arrancar en dev
cp .env.example .env.local
# edita .env.local y rellena al menos OPENROUTER_API_KEY + QR_ADMIN_PASS
npm install
npm run dev
# http://localhost:3000 → web pública
# http://localhost:3000/carta → carta limpia
# http://localhost:3000/qr → admin de QRs (pass: la de QR_ADMIN_PASS)
# http://localhost:3000/api/menu → JSON actual
# historial (requiere auth, nunca en la URL — ver más abajo):
curl -u "x:<QR_ADMIN_PASS>" http://localhost:3000/api/menu/log/api/menu/log exige la misma QR_ADMIN_PASS que /qr, pero nunca como parámetro de la
URL: solo por cabecera Authorization: Basic (como el curl -u de arriba, o desde el
navegador si WWW-Authenticate lanza el diálogo nativo) o por la cookie de sesión que deja
/qr al iniciar sesión. Una contraseña en la URL queda escrita en los logs del servidor,
en el historial del navegador y en la cabecera Referer de cualquier enlace que se siga
desde ahí — por eso nunca va ahí.
Sin Vercel KV configurado, el proyecto usa un fallback en memoria + el
JSON de data/menu.json. Los cambios del agente se ven en /carta hasta
el siguiente reinicio del servidor.
- Cambios estructurales (nuevas categorías, nuevos tipos de acción):
edita
lib/types.ts,lib/actions.tsy el prompt enlib/prompts.ts. - Tono de las respuestas de reseñas: ajusta el system prompt en
lib/prompts.ts(REVIEW_REPLY_SYSTEM). - Texto del "número no autorizado":
app/api/whatsapp/webhook/route.ts. - Tasa máxima de reseñas/hora: constante
ALLOWED_PROCESSED_PER_HOURenapp/api/reviews/poll/route.ts. - Tiempo de espera de confirmación:
CONFIRM_TTL_MSenlib/pending.ts.
El proyecto está conectado a AngelReml/donpepe. Cada push a main dispara
un despliegue de producción en Vercel; las demás ramas generan una vista
previa. Ya no hace falta vercel --prod desde el disco local, y así no se
puede desplegar nada que no esté versionado.